Compare commits

...

20 Commits

Author SHA1 Message Date
Rıdvan Akca
c62aea3852 feat(changedetection): add delete watch action 2024-05-13 16:31:58 +02:00
Rıdvan Akca
58c90da385 feat(changedetection): add create watch action 2024-05-13 16:31:18 +02:00
Rıdvan Akca
c095d3138b feat(changedetection): add changedetection integration 2024-05-13 16:28:05 +02:00
Ali BARIN
9548c93b4c Merge pull request #1867 from automatisch/custom-user-seed
add POST /api/v1/installation/users to seed user
2024-05-13 16:19:37 +02:00
Ali BARIN
4144944ab2 refactor(migrations): rename installation completed migration 2024-05-13 14:12:46 +00:00
Ali BARIN
46b85519c1 refactor(User/createAdmin): mark installation completed 2024-05-13 14:10:21 +00:00
Ali BARIN
5a83fc33ec refactor(User): rename createAdminUser with createAdmin 2024-05-13 14:09:09 +00:00
Ali BARIN
c80791267f refactor(installation): improve allow installation guard 2024-05-13 14:00:12 +00:00
Ali BARIN
b30f97db3e feat: add POST /api/v1/installation/users to seed user 2024-05-13 13:31:16 +00:00
Ali BARIN
717c81fa2b test(global-hooks): truncate config table 2024-05-13 13:31:16 +00:00
Ali BARIN
ae188bc563 feat: add migration to mark userful instances installation completed 2024-05-13 13:31:16 +00:00
Ali BARIN
fc4561221d feat: add DISABLE_SEED_USER to bypass yarn db:seed:user command 2024-05-13 13:31:16 +00:00
Ali BARIN
5aeb4f8809 Merge pull request #1883 from automatisch/checkisenterprise
feat: remove checkIsEnterprise middleware from admin users
2024-05-13 15:30:55 +02:00
Ali BARIN
c6c900bc39 test(get-users.ee): remove license mock 2024-05-13 13:20:48 +00:00
Rıdvan Akca
c18ab67a25 feat: remove checkIsEnterprise middleware from admin users 2024-05-13 15:02:10 +02:00
Ali BARIN
55ae1470d0 Merge pull request #1875 from automatisch/logout-saml 2024-05-13 13:51:49 +02:00
Ömer Faruk Aydın
a1136fdfb2 Merge pull request #1882 from automatisch/no-proxy
feat: add no_proxy support in http(s) agents
2024-05-13 13:43:39 +02:00
Ali BARIN
3da5e13ecd feat: support bi-directional backchannel SAML SLO 2024-05-10 08:58:32 +00:00
Ali BARIN
40d0fe0db6 chore: add saml_session_id property in access token 2024-05-10 08:54:02 +00:00
Ali BARIN
029fd2d0b0 chore(devcontainer): remove keycloak custom container name 2024-05-10 08:54:02 +00:00
39 changed files with 554 additions and 29 deletions

View File

@@ -36,7 +36,6 @@ services:
keycloak:
image: quay.io/keycloak/keycloak:21.1
restart: always
container_name: keycloak
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin

View File

@@ -2,6 +2,7 @@ import appConfig from '../../src/config/app.js';
import logger from '../../src/helpers/logger.js';
import client from './client.js';
import User from '../../src/models/user.js';
import Config from '../../src/models/config.js';
import Role from '../../src/models/role.js';
import '../../src/config/orm.js';
import process from 'process';
@@ -21,6 +22,14 @@ export async function createUser(
email = 'user@automatisch.io',
password = 'sample'
) {
if (appConfig.disableSeedUser) {
logger.info('Seed user is disabled.');
process.exit(0);
return;
}
const UNIQUE_VIOLATION_CODE = '23505';
const role = await fetchAdminRole();
@@ -37,6 +46,8 @@ export async function createUser(
if (userCount === 0) {
const user = await User.query().insertAndFetch(userParams);
logger.info(`User has been saved: ${user.email}`);
await Config.markInstallationCompleted();
} else {
logger.info('No need to seed a user.');
}

View File

@@ -67,6 +67,7 @@
"pluralize": "^8.0.0",
"raw-body": "^2.5.2",
"showdown": "^2.1.0",
"uuid": "^9.0.1",
"winston": "^3.7.1",
"xmlrpc": "^1.3.2"
},

View File

@@ -0,0 +1,29 @@
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Create a watch',
key: 'createWatch',
description: 'Creates a new change detection watch for a specific website.',
arguments: [
{
label: 'URL',
key: 'url',
type: 'string',
required: true,
variables: true,
description: 'Url you want to monitor',
},
],
async run($) {
const url = $.step.parameters.url;
const body = {
url,
};
const response = await $.http.post('/v1/watch', body);
$.setActionItem({ raw: response.data });
},
});

View File

@@ -0,0 +1,29 @@
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Delete a watch',
key: 'deleteWatch',
description: 'Deletes a change detection watch.',
arguments: [
{
label: 'Watch ID',
key: 'watchId',
type: 'string',
required: true,
variables: true,
description: 'Watch id you want to delete',
},
],
async run($) {
const watchId = $.step.parameters.watchId;
await $.http.delete(`/v1/watch/${watchId}`);
$.setActionItem({
raw: {
result: 'successful',
},
});
},
});

View File

@@ -0,0 +1,4 @@
import createWatch from './create-watch/index.js';
import deleteWatch from './delete-watch/index.js';
export default [createWatch, deleteWatch];

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -0,0 +1,44 @@
import verifyCredentials from './verify-credentials.js';
import isStillVerified from './is-still-verified.js';
export default {
fields: [
{
key: 'screenName',
label: 'Screen Name',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description:
'Screen name of your connection to be used on Automatisch UI.',
clickToCopy: false,
},
{
key: 'instanceUrl',
label: 'Instance URL',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
{
key: 'apiKey',
label: 'API Key',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'Changedetection API key of your account.',
clickToCopy: false,
},
],
verifyCredentials,
isStillVerified,
};

View File

@@ -0,0 +1,8 @@
import verifyCredentials from './verify-credentials.js';
const isStillVerified = async ($) => {
await verifyCredentials($);
return true;
};
export default isStillVerified;

View File

@@ -0,0 +1,10 @@
const verifyCredentials = async ($) => {
await $.http.get('/v1/systeminfo');
await $.auth.set({
screenName: $.auth.data.screenName,
apiKey: $.auth.data.apiKey,
});
};
export default verifyCredentials;

View File

@@ -0,0 +1,9 @@
const addAuthHeader = ($, requestConfig) => {
if ($.auth.data?.apiKey) {
requestConfig.headers['x-api-key'] = $.auth.data.apiKey;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -0,0 +1,10 @@
const setBaseUrl = ($, requestConfig) => {
const instanceUrl = $.auth.data.instanceUrl;
if (instanceUrl) {
requestConfig.baseURL = `${instanceUrl}/api`;
}
return requestConfig;
};
export default setBaseUrl;

View File

@@ -0,0 +1,19 @@
import defineApp from '../../helpers/define-app.js';
import addAuthHeader from './common/add-auth-header.js';
import auth from './auth/index.js';
import setBaseUrl from './common/set-base-url.js';
import actions from './actions/index.js';
export default defineApp({
name: 'Changedetection',
key: 'changedetection',
iconUrl: '{BASE_URL}/apps/changedetection/assets/favicon.svg',
authDocUrl: '{DOCS_URL}/apps/changedetection/connection',
supportsConnections: true,
baseUrl: 'https://changedetection.io',
apiBaseUrl: '',
primaryColor: '3056d3',
beforeRequest: [setBaseUrl, addAuthHeader],
auth,
actions,
});

View File

@@ -98,6 +98,7 @@ const appConfig = {
disableFavicon: process.env.DISABLE_FAVICON === 'true',
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
disableSeedUser: process.env.DISABLE_SEED_USER === 'true',
};
if (!appConfig.encryptionKey) {

View File

@@ -1,11 +1,10 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../../app';
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../../../../test/factories/role';
import { createUser } from '../../../../../../test/factories/user';
import getUsersMock from '../../../../../../test/mocks/rest/api/v1/admin/users/get-users.js';
import * as license from '../../../../../helpers/license.ee.js';
describe('GET /api/v1/admin/users', () => {
let currentUser, currentUserRole, anotherUser, anotherUserRole, token;
@@ -32,8 +31,6 @@ describe('GET /api/v1/admin/users', () => {
});
it('should return users data', async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
const response = await request(app)
.get('/api/v1/admin/users')
.set('Authorization', token)

View File

@@ -0,0 +1,9 @@
import User from '../../../../../models/user.js';
export default async (request, response) => {
const { email, password, fullName } = request.body;
await User.createAdmin({ email, password, fullName });
response.status(204).end();
};

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../../app.js';
import Config from '../../../../../models/config.js';
import User from '../../../../../models/user.js';
import { createRole } from '../../../../../../test/factories/role';
import { createUser } from '../../../../../../test/factories/user';
import { createInstallationCompletedConfig } from '../../../../../../test/factories/config';
describe('POST /api/v1/installation/users', () => {
let adminRole;
beforeEach(async () => {
adminRole = await createRole({
name: 'Admin',
key: 'admin',
})
});
describe('for incomplete installations', () => {
it('should respond with HTTP 204 with correct payload when no user', async () => {
expect(await Config.isInstallationCompleted()).toBe(false);
await request(app)
.post('/api/v1/installation/users')
.send({
email: 'user@automatisch.io',
password: 'password',
fullName: 'Initial admin'
})
.expect(204);
const user = await User.query().findOne({ email: 'user@automatisch.io' });
expect(user.roleId).toBe(adminRole.id);
expect(await Config.isInstallationCompleted()).toBe(true);
});
it('should respond with HTTP 403 with correct payload when one user exists at least', async () => {
expect(await Config.isInstallationCompleted()).toBe(false);
await createUser();
const usersCountBefore = await User.query().resultSize();
await request(app)
.post('/api/v1/installation/users')
.send({
email: 'user@automatisch.io',
password: 'password',
fullName: 'Initial admin'
})
.expect(403);
const usersCountAfter = await User.query().resultSize();
expect(usersCountBefore).toEqual(usersCountAfter);
});
});
describe('for completed installations', () => {
beforeEach(async () => {
await createInstallationCompletedConfig();
});
it('should respond with HTTP 403 when installation completed', async () => {
expect(await Config.isInstallationCompleted()).toBe(true);
await request(app)
.post('/api/v1/installation/users')
.send({
email: 'user@automatisch.io',
password: 'password',
fullName: 'Initial admin'
})
.expect(403);
const user = await User.query().findOne({ email: 'user@automatisch.io' });
expect(user).toBeUndefined();
expect(await Config.isInstallationCompleted()).toBe(true);
});
})
});

View File

@@ -0,0 +1,11 @@
export async function up(knex) {
return knex.schema.table('access_tokens', (table) => {
table.string('saml_session_id').nullable();
});
}
export async function down(knex) {
return knex.schema.table('access_tokens', (table) => {
table.dropColumn('saml_session_id');
});
}

View File

@@ -0,0 +1,17 @@
export async function up(knex) {
const users = await knex('users').limit(1);
// no user implies installation is not completed yet.
if (users.length === 0) return;
await knex('config').insert({
key: 'installation.completed',
value: {
data: true
}
});
};
export async function down(knex) {
await knex('config').where({ key: 'installation.completed' }).delete();
};

View File

@@ -0,0 +1,16 @@
import Config from '../models/config.js';
import User from '../models/user.js';
export async function allowInstallation(request, response, next) {
if (await Config.isInstallationCompleted()) {
return response.status(403).end();
}
const hasAnyUsers = await User.query().resultSize() > 0;
if (hasAnyUsers) {
return response.status(403).end();
}
next();
};

View File

@@ -4,12 +4,13 @@ import AccessToken from '../models/access-token.js';
const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds
const createAuthTokenByUserId = async (userId) => {
const createAuthTokenByUserId = async (userId, samlSessionId) => {
const user = await User.query().findById(userId).throwIfNotFound();
const token = await crypto.randomBytes(48).toString('hex');
await AccessToken.query().insert({
token,
samlSessionId,
userId: user.id,
expiresIn: TOKEN_EXPIRES_IN,
});

View File

@@ -5,8 +5,11 @@ import passport from 'passport';
import appConfig from '../config/app.js';
import createAuthTokenByUserId from './create-auth-token-by-user-id.js';
import SamlAuthProvider from '../models/saml-auth-provider.ee.js';
import AccessToken from '../models/access-token.js';
import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js';
const asyncNoop = async () => { };
export default function configurePassport(app) {
app.use(
passport.initialize({
@@ -19,6 +22,10 @@ export default function configurePassport(app) {
{
passReqToCallback: true,
getSamlOptions: async function (request, done) {
// This is a workaround to avoid session logout which passport-saml enforces
request.logout = asyncNoop;
request.logOut = asyncNoop;
const { issuer } = request.params;
const notFoundIssuer = new Error('Issuer cannot be found!');
@@ -35,7 +42,7 @@ export default function configurePassport(app) {
return done(null, authProvider.config);
},
},
async function (request, user, done) {
async function signonVerify(request, user, done) {
const { issuer } = request.params;
const notFoundIssuer = new Error('Issuer cannot be found!');
@@ -53,10 +60,38 @@ export default function configurePassport(app) {
user,
authProvider
);
request.samlSessionId = user.sessionIndex;
return done(null, foundUserWithIdentity);
},
function (request, user, done) {
return done(null, null);
async function logoutVerify(request, user, done) {
const { issuer } = request.params;
const notFoundIssuer = new Error('Issuer cannot be found!');
if (!issuer) return done(notFoundIssuer);
const authProvider = await SamlAuthProvider.query().findOne({
issuer: request.params.issuer,
});
if (!authProvider) {
return done(notFoundIssuer);
}
const foundUserWithIdentity = await findOrCreateUserBySamlIdentity(
user,
authProvider
);
const accessToken = await AccessToken.query().findOne({
revoked_at: null,
saml_session_id: user.sessionIndex,
}).throwIfNotFound();
await accessToken.revoke();
return done(null, foundUserWithIdentity);
}
)
);
@@ -73,17 +108,22 @@ export default function configurePassport(app) {
'/login/saml/:issuer/callback',
passport.authenticate('saml', {
session: false,
failureRedirect: '/',
failureFlash: true,
}),
async (req, res) => {
const token = await createAuthTokenByUserId(req.currentUser.id);
async (request, response) => {
const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId);
const redirectUrl = new URL(
`/login/callback?token=${token}`,
appConfig.webAppUrl
).toString();
res.redirect(redirectUrl);
response.redirect(redirectUrl);
}
);
app.post(
'/logout/saml/:issuer',
passport.authenticate('saml', {
session: false,
}),
);
}

View File

@@ -12,6 +12,7 @@ class AccessToken extends Base {
id: { type: 'string', format: 'uuid' },
userId: { type: 'string', format: 'uuid' },
token: { type: 'string', minLength: 32 },
samlSessionId: { type: ['string', 'null'] },
expiresIn: { type: 'integer' },
revokedAt: { type: ['string', 'null'], format: 'date-time' },
},
@@ -28,8 +29,37 @@ class AccessToken extends Base {
},
});
async terminateRemoteSamlSession() {
if (!this.samlSessionId) {
return;
}
const user = await this
.$relatedQuery('user');
const firstIdentity = await user
.$relatedQuery('identities')
.first();
const samlAuthProvider = await firstIdentity
.$relatedQuery('samlAuthProvider')
.throwIfNotFound();
const response = await samlAuthProvider.terminateRemoteSession(this.samlSessionId);
return response;
}
async revoke() {
return await this.$query().patch({ revokedAt: new Date().toISOString() });
const response = await this.$query().patch({ revokedAt: new Date().toISOString() });
try {
await this.terminateRemoteSamlSession();
} catch (error) {
// TODO: should it silently fail or not?
}
return response;
}
}

View File

@@ -13,6 +13,28 @@ class Config extends Base {
value: { type: 'object' },
},
};
static async isInstallationCompleted() {
const installationCompletedEntry = await this
.query()
.where({
key: 'installation.completed'
})
.first();
const installationCompleted = installationCompletedEntry?.value?.data === true;
return installationCompleted;
}
static async markInstallationCompleted() {
return await this.query().insert({
key: 'installation.completed',
value: {
data: true,
},
});
}
}
export default Config;

View File

@@ -45,6 +45,10 @@ class Role extends Base {
get isAdmin() {
return this.key === 'admin';
}
static async findAdmin() {
return await this.query().findOne({ key: 'admin' });
}
}
export default Role;

View File

@@ -1,5 +1,7 @@
import { URL } from 'node:url';
import { v4 as uuidv4 } from 'uuid';
import appConfig from '../config/app.js';
import axios from '../helpers/axios-with-proxy.js';
import Base from './base.js';
import Identity from './identity.ee.js';
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
@@ -61,27 +63,71 @@ class SamlAuthProvider extends Base {
});
static get virtualAttributes() {
return ['loginUrl'];
return ['loginUrl', 'remoteLogoutUrl'];
}
get loginUrl() {
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
}
get config() {
const callbackUrl = new URL(
get loginCallBackUrl() {
return new URL(
`/login/saml/${this.issuer}/callback`,
appConfig.baseUrl
).toString();
}
get remoteLogoutUrl() {
return this.entryPoint;
}
get config() {
return {
callbackUrl,
callbackUrl: this.loginCallBackUrl,
cert: this.certificate,
entryPoint: this.entryPoint,
issuer: this.issuer,
signatureAlgorithm: this.signatureAlgorithm,
logoutUrl: this.remoteLogoutUrl
};
}
generateLogoutRequestBody(sessionId) {
const logoutRequest = `
<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="${uuidv4()}"
Version="2.0"
IssueInstant="${new Date().toISOString()}"
Destination="${this.remoteLogoutUrl}">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${this.issuer}</saml:Issuer>
<samlp:SessionIndex>${sessionId}</samlp:SessionIndex>
</samlp:LogoutRequest>
`;
const encodedLogoutRequest = Buffer.from(logoutRequest).toString('base64')
return encodedLogoutRequest
}
async terminateRemoteSession(sessionId) {
const logoutRequest = this.generateLogoutRequestBody(sessionId);
const response = await axios.post(
this.remoteLogoutUrl,
new URLSearchParams({
SAMLRequest: logoutRequest,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}
}
);
return response;
}
}
export default SamlAuthProvider;

View File

@@ -10,6 +10,7 @@ import Base from './base.js';
import App from './app.js';
import AccessToken from './access-token.js';
import Connection from './connection.js';
import Config from './config.js';
import Execution from './execution.js';
import Flow from './flow.js';
import Identity from './identity.ee.js';
@@ -373,6 +374,21 @@ class User extends Base {
return apps;
}
static async createAdmin({ email, password, fullName }) {
const adminRole = await Role.findAdmin();
const adminUser = await this.query().insert({
email,
password,
fullName,
roleId: adminRole.id
});
await Config.markInstallationCompleted();
return adminUser;
}
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);

View File

@@ -2,25 +2,17 @@ import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../../helpers/authentication.js';
import { authorizeAdmin } from '../../../../helpers/authorization.js';
import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js';
import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js';
import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js';
const router = Router();
router.get(
'/',
authenticateUser,
authorizeAdmin,
checkIsEnterprise,
asyncHandler(getUsersAction)
);
router.get('/', authenticateUser, authorizeAdmin, asyncHandler(getUsersAction));
router.get(
'/:userId',
authenticateUser,
authorizeAdmin,
checkIsEnterprise,
asyncHandler(getUserAction)
);

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { allowInstallation } from '../../../../helpers/allow-installation.js';
import createUserAction from '../../../../controllers/api/v1/installation/users/create-user.js';
const router = Router();
router.post(
'/',
allowInstallation,
asyncHandler(createUserAction)
);
export default router;

View File

@@ -18,6 +18,7 @@ import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.
import rolesRouter from './api/v1/admin/roles.ee.js';
import permissionsRouter from './api/v1/admin/permissions.ee.js';
import adminUsersRouter from './api/v1/admin/users.ee.js';
import installationUsersRouter from './api/v1/installation/users.js';
const router = Router();
@@ -40,5 +41,7 @@ router.use('/api/v1/admin/users', adminUsersRouter);
router.use('/api/v1/admin/roles', rolesRouter);
router.use('/api/v1/admin/permissions', permissionsRouter);
router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter);
router.use('/api/v1/installation/users', installationUsersRouter);
export default router;

View File

@@ -11,3 +11,7 @@ export const createConfig = async (params = {}) => {
return config;
};
export const createInstallationCompletedConfig = async () => {
return await createConfig({ key: 'installation.completed', value: { data: true } });
}

View File

@@ -8,7 +8,7 @@ global.beforeAll(async () => {
logger.silent = true;
// Remove default roles and permissions before running the test suite
await knex.raw('TRUNCATE TABLE roles, permissions CASCADE');
await knex.raw('TRUNCATE TABLE config, roles, permissions CASCADE');
});
global.beforeEach(async () => {

View File

@@ -41,6 +41,14 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/carbone/connection' },
],
},
{
text: 'Changedetection',
collapsible: true,
collapsed: true,
items: [
{ text: 'Connection', link: '/apps/changedetection/connection' },
],
},
{
text: 'Datastore',
collapsible: true,

View File

@@ -0,0 +1,14 @@
---
favicon: /favicons/changedetection.svg
items:
- name: Create a watch
desc: Creates a new change detection watch for a specific website.
- name: Delete a watch
desc: Deletes a change detection watch.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -0,0 +1,14 @@
# Changedetection
:::info
This page explains the steps you need to follow to set up the Changedetection
connection in Automatisch. If any of the steps are outdated, please let us know!
:::
1. Go to your Changedetection admin panel.
2. Click on the **Settings** button.
3. Click on the **API** tab.
4. Copy **API key** from the page to the `API Key` field on Automatisch.
5. Add your Instance URL in the **Instance URL** field on Automatisch.
6. Write any screen name to be displayed in Automatisch.
7. Now, you can start using the Changedetection connection with Automatisch.

View File

@@ -3,6 +3,7 @@
The following integrations are currently supported by Automatisch.
- [Carbone](/apps/carbone/actions)
- [Changedetection](/apps/changedetection/actions)
- [Datastore](/apps/datastore/actions)
- [DeepL](/apps/deepl/actions)
- [Delay](/apps/delay/actions)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -16024,6 +16024,11 @@ uuid@^9.0.0:
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
uuid@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
v8-compile-cache@^2.0.3:
version "2.3.0"
resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"