mirror of
https://github.com/thiloho/archtika.git
synced 2025-11-22 10:51:36 +01:00
Merge pull request #17 from archtika/devel
Various Refactorings and QoL improvements
This commit is contained in:
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1726463316,
|
"lastModified": 1729256560,
|
||||||
"narHash": "sha256-gI9kkaH0ZjakJOKrdjaI/VbaMEo9qBbSUl93DnU7f4c=",
|
"narHash": "sha256-/uilDXvCIEs3C9l73JTACm4quuHUsIHcns1c+cHUJwA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "99dc8785f6a0adac95f5e2ab05cc2e1bf666d172",
|
"rev": "4c2fcb090b1f3e5b47eaa7bd33913b574a11e0a0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
11
flake.nix
11
flake.nix
@@ -23,7 +23,10 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
api = pkgs.mkShell {
|
api = pkgs.mkShell {
|
||||||
packages = with pkgs; [ postgresql_16 ];
|
packages = with pkgs; [
|
||||||
|
postgresql_16
|
||||||
|
postgrest
|
||||||
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
alias dbmate="${pkgs.dbmate}/bin/dbmate --no-dump-schema --url postgres://postgres@localhost:15432/archtika?sslmode=disable"
|
alias dbmate="${pkgs.dbmate}/bin/dbmate --no-dump-schema --url postgres://postgres@localhost:15432/archtika?sslmode=disable"
|
||||||
alias formatsql="${pkgs.pgformatter}/bin/pg_format -s 2 -f 2 -U 2 -i db/migrations/*.sql"
|
alias formatsql="${pkgs.pgformatter}/bin/pg_format -s 2 -f 2 -U 2 -i db/migrations/*.sql"
|
||||||
@@ -49,6 +52,8 @@
|
|||||||
dev-vm = self.nixosConfigurations.dev-vm.config.system.build.vm;
|
dev-vm = self.nixosConfigurations.dev-vm.config.system.build.vm;
|
||||||
|
|
||||||
default = pkgs.callPackage ./nix/package.nix { };
|
default = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
|
||||||
|
docker = pkgs.callPackage ./nix/docker.nix { };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,8 +67,12 @@
|
|||||||
type = "app";
|
type = "app";
|
||||||
program = "${pkgs.writeShellScriptBin "api-setup" ''
|
program = "${pkgs.writeShellScriptBin "api-setup" ''
|
||||||
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
|
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
|
||||||
|
WEBSITE_MAX_STORAGE_SIZE=100
|
||||||
|
WEBSITE_MAX_NUMBER_USER=3
|
||||||
|
|
||||||
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.jwt_secret\" TO '$JWT_SECRET'"
|
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.jwt_secret\" TO '$JWT_SECRET'"
|
||||||
|
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.website_max_storage_size\" TO $WEBSITE_MAX_STORAGE_SIZE"
|
||||||
|
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.website_max_number_user\" TO $WEBSITE_MAX_NUMBER_USER"
|
||||||
|
|
||||||
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:15432/archtika?sslmode=disable --migrations-dir ${self.outPath}/rest-api/db/migrations up
|
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:15432/archtika?sslmode=disable --migrations-dir ${self.outPath}/rest-api/db/migrations up
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
acmeEmail = "thilo.hohlt@tutanota.com";
|
acmeEmail = "thilo.hohlt@tutanota.com";
|
||||||
dnsProvider = "porkbun";
|
dnsProvider = "porkbun";
|
||||||
dnsEnvironmentFile = /var/lib/porkbun.env;
|
dnsEnvironmentFile = /var/lib/porkbun.env;
|
||||||
disableRegistration = true;
|
settings = {
|
||||||
|
disableRegistration = true;
|
||||||
|
maxWebsiteStorageSize = 250;
|
||||||
|
maxUserWebsites = 3;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
|
|
||||||
virtualisation = {
|
virtualisation = {
|
||||||
graphics = false;
|
graphics = false;
|
||||||
|
memorySize = 2048;
|
||||||
|
cores = 2;
|
||||||
|
diskSize = 10240;
|
||||||
sharedDirectories = {
|
sharedDirectories = {
|
||||||
websites = {
|
websites = {
|
||||||
source = "/var/www/archtika-websites";
|
source = "/var/www/archtika-websites";
|
||||||
@@ -49,6 +52,15 @@
|
|||||||
postgresql = {
|
postgresql = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs.postgresql_16;
|
package = pkgs.postgresql_16;
|
||||||
|
/*
|
||||||
|
PL/Perl:
|
||||||
|
overrideAttrs (
|
||||||
|
finalAttrs: previousAttrs: {
|
||||||
|
buildInputs = previousAttrs.buildInputs ++ [ pkgs.perl ];
|
||||||
|
configureFlags = previousAttrs.configureFlags ++ [ "--with-perl" ];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
*/
|
||||||
ensureDatabases = [ "archtika" ];
|
ensureDatabases = [ "archtika" ];
|
||||||
authentication = lib.mkForce ''
|
authentication = lib.mkForce ''
|
||||||
local all all trust
|
local all all trust
|
||||||
@@ -59,6 +71,11 @@
|
|||||||
};
|
};
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
recommendedProxySettings = true;
|
||||||
|
recommendedTlsSettings = true;
|
||||||
|
recommendedZstdSettings = true;
|
||||||
|
recommendedOptimisation = true;
|
||||||
|
|
||||||
virtualHosts."_" = {
|
virtualHosts."_" = {
|
||||||
listen = [
|
listen = [
|
||||||
{
|
{
|
||||||
@@ -67,13 +84,15 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
locations = {
|
locations = {
|
||||||
|
"/previews/" = {
|
||||||
|
alias = "/var/www/archtika-websites/previews/";
|
||||||
|
index = "index.html";
|
||||||
|
tryFiles = "$uri $uri/ $uri.html =404";
|
||||||
|
};
|
||||||
"/" = {
|
"/" = {
|
||||||
root = "/var/www/archtika-websites";
|
root = "/var/www/archtika-websites";
|
||||||
index = "index.html";
|
index = "index.html";
|
||||||
tryFiles = "$uri $uri/ $uri.html =404";
|
tryFiles = "$uri $uri/ $uri.html =404";
|
||||||
extraConfig = ''
|
|
||||||
autoindex on;
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
45
nix/docker.nix
Normal file
45
nix/docker.nix
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
# Behaviour of the Nix module needs to be replicated, which includes PostgreSQL, NGINX, ACME (DNS01), env variables, etc.
|
||||||
|
# Basic initialisation template can be found below
|
||||||
|
let
|
||||||
|
archtika = pkgs.callPackage ./package.nix { };
|
||||||
|
|
||||||
|
postgresConf = pkgs.writeText "postgres.conf" ''
|
||||||
|
|
||||||
|
'';
|
||||||
|
|
||||||
|
nginxConf = pkgs.writeText "nginx.conf" ''
|
||||||
|
|
||||||
|
'';
|
||||||
|
|
||||||
|
entrypoint = pkgs.writeShellScriptBin "entrypoint" ''
|
||||||
|
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
pkgs.dockerTools.buildLayeredImage {
|
||||||
|
name = "archtika";
|
||||||
|
tag = "latest";
|
||||||
|
contents = [
|
||||||
|
archtika
|
||||||
|
entrypoint
|
||||||
|
pkgs.postgresql_16
|
||||||
|
pkgs.nginx
|
||||||
|
pkgs.acme-sh
|
||||||
|
pkgs.bash
|
||||||
|
pkgs.coreutils
|
||||||
|
];
|
||||||
|
config = {
|
||||||
|
Cmd = [ "${entrypoint}/bin/entrypoint" ];
|
||||||
|
ExposedPorts = {
|
||||||
|
"80" = { };
|
||||||
|
"443" = { };
|
||||||
|
};
|
||||||
|
Volumes = {
|
||||||
|
"/var/lib/postgresql/data" = { };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -76,10 +76,26 @@ in
|
|||||||
description = "API secrets for the DNS-01 challenge (required for wildcard domains).";
|
description = "API secrets for the DNS-01 challenge (required for wildcard domains).";
|
||||||
};
|
};
|
||||||
|
|
||||||
disableRegistration = mkOption {
|
settings = mkOption {
|
||||||
type = types.bool;
|
type = types.submodule {
|
||||||
default = false;
|
options = {
|
||||||
description = "By default any user can create an account. That behavior can be disabled by using this option.";
|
disableRegistration = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "By default any user can create an account. That behavior can be disabled by using this option.";
|
||||||
|
};
|
||||||
|
maxUserWebsites = mkOption {
|
||||||
|
type = types.int;
|
||||||
|
default = 2;
|
||||||
|
description = "Maximum number of websites allowed per user by default.";
|
||||||
|
};
|
||||||
|
maxWebsiteStorageSize = mkOption {
|
||||||
|
type = types.int;
|
||||||
|
default = 500;
|
||||||
|
description = "Maximum amount of disk space in MB allowed per user website by default.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +107,7 @@ in
|
|||||||
|
|
||||||
users.groups.${cfg.group} = { };
|
users.groups.${cfg.group} = { };
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0755 ${cfg.user} ${cfg.group} -" ];
|
systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0777 ${cfg.user} ${cfg.group} -" ];
|
||||||
|
|
||||||
systemd.services.archtika-api = {
|
systemd.services.archtika-api = {
|
||||||
description = "archtika API service";
|
description = "archtika API service";
|
||||||
@@ -105,12 +121,15 @@ in
|
|||||||
User = cfg.user;
|
User = cfg.user;
|
||||||
Group = cfg.group;
|
Group = cfg.group;
|
||||||
Restart = "always";
|
Restart = "always";
|
||||||
|
WorkingDirectory = "${cfg.package}/rest-api";
|
||||||
};
|
};
|
||||||
|
|
||||||
script = ''
|
script = ''
|
||||||
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
|
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
|
||||||
|
|
||||||
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'"
|
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'"
|
||||||
|
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_storage_size\" TO ${toString cfg.settings.maxWebsiteStorageSize}"
|
||||||
|
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_number_user\" TO ${toString cfg.settings.maxUserWebsites}"
|
||||||
|
|
||||||
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up
|
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up
|
||||||
|
|
||||||
@@ -131,7 +150,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
script = ''
|
script = ''
|
||||||
REGISTRATION_IS_DISABLED=${toString cfg.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app
|
REGISTRATION_IS_DISABLED=${toString cfg.settings.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,7 +207,7 @@ in
|
|||||||
default_type application/json;
|
default_type application/json;
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
"/api/rpc/register" = mkIf cfg.disableRegistration {
|
"/api/rpc/register" = mkIf cfg.settings.disableRegistration {
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
deny all;
|
deny all;
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ CREATE ROLE anon NOLOGIN NOINHERIT;
|
|||||||
|
|
||||||
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
||||||
|
|
||||||
|
CREATE ROLE administrator NOLOGIN;
|
||||||
|
|
||||||
GRANT anon TO authenticator;
|
GRANT anon TO authenticator;
|
||||||
|
|
||||||
GRANT authenticated_user TO authenticator;
|
GRANT authenticated_user TO authenticator;
|
||||||
|
|
||||||
|
GRANT administrator TO authenticator;
|
||||||
|
|
||||||
|
GRANT authenticated_user TO administrator;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA api TO anon;
|
GRANT USAGE ON SCHEMA api TO anon;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA api TO authenticated_user;
|
GRANT USAGE ON SCHEMA api TO authenticated_user;
|
||||||
@@ -23,9 +29,10 @@ ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
|
|||||||
|
|
||||||
CREATE TABLE internal.user (
|
CREATE TABLE internal.user (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
|
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
|
||||||
password_hash CHAR(60) NOT NULL,
|
password_hash CHAR(60) NOT NULL,
|
||||||
role NAME NOT NULL DEFAULT 'authenticated_user',
|
user_role NAME NOT NULL DEFAULT 'authenticated_user',
|
||||||
|
max_number_websites INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_number_user') ::INT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,11 +41,11 @@ CREATE TABLE internal.website (
|
|||||||
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
|
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
|
||||||
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
|
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
|
||||||
|
max_storage_size INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_storage_size') ::INT,
|
||||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
||||||
title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE internal.media (
|
CREATE TABLE internal.media (
|
||||||
@@ -74,7 +81,8 @@ CREATE TABLE internal.header (
|
|||||||
|
|
||||||
CREATE TABLE internal.home (
|
CREATE TABLE internal.home (
|
||||||
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
|
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
|
||||||
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
|
main_content VARCHAR(200000) NOT NULL CHECK (TRIM(main_content) != ''),
|
||||||
|
meta_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
@@ -84,7 +92,7 @@ CREATE TABLE internal.docs_category (
|
|||||||
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
|
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
|
||||||
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
|
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
|
||||||
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
|
category_weight INT CHECK (category_weight >= 0) NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
@@ -101,13 +109,12 @@ CREATE TABLE internal.article (
|
|||||||
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
|
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
|
||||||
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
|
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
|
||||||
publication_date DATE,
|
publication_date DATE,
|
||||||
main_content TEXT CHECK (TRIM(main_content) != ''),
|
main_content VARCHAR(200000) CHECK (TRIM(main_content) != ''),
|
||||||
category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL,
|
category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL,
|
||||||
article_weight INTEGER CHECK (article_weight IS NULL OR article_weight >= 0),
|
article_weight INT CHECK (article_weight IS NULL OR article_weight >= 0),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
title_description_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(title, '') || ' ' || COALESCE(meta_description, ''))) STORED,
|
|
||||||
UNIQUE (website_id, category, article_weight)
|
UNIQUE (website_id, category, article_weight)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,7 +127,7 @@ CREATE TABLE internal.footer (
|
|||||||
|
|
||||||
CREATE TABLE internal.legal_information (
|
CREATE TABLE internal.legal_information (
|
||||||
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
|
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
|
||||||
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
|
main_content VARCHAR(200000) NOT NULL CHECK (TRIM(main_content) != ''),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
||||||
@@ -129,7 +136,7 @@ CREATE TABLE internal.legal_information (
|
|||||||
CREATE TABLE internal.collab (
|
CREATE TABLE internal.collab (
|
||||||
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
|
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
|
||||||
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
|
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
|
||||||
permission_level INTEGER CHECK (permission_level IN (10, 20, 30)) NOT NULL DEFAULT 10,
|
permission_level INT CHECK (permission_level IN (10, 20, 30)) NOT NULL DEFAULT 10,
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
added_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
@@ -167,6 +174,8 @@ DROP ROLE anon;
|
|||||||
|
|
||||||
DROP ROLE authenticated_user;
|
DROP ROLE authenticated_user;
|
||||||
|
|
||||||
|
DROP ROLE administrator;
|
||||||
|
|
||||||
DROP ROLE authenticator;
|
DROP ROLE authenticator;
|
||||||
|
|
||||||
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
|
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- migrate:up
|
-- migrate:up
|
||||||
CREATE FUNCTION pgrst_watch ()
|
CREATE FUNCTION internal.pgrst_watch ()
|
||||||
RETURNS EVENT_TRIGGER
|
RETURNS EVENT_TRIGGER
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -10,10 +10,10 @@ $$
|
|||||||
LANGUAGE plpgsql;
|
LANGUAGE plpgsql;
|
||||||
|
|
||||||
CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
|
CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
|
||||||
EXECUTE FUNCTION pgrst_watch ();
|
EXECUTE FUNCTION internal.pgrst_watch ();
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP EVENT TRIGGER pgrst_watch;
|
DROP EVENT TRIGGER pgrst_watch;
|
||||||
|
|
||||||
DROP FUNCTION pgrst_watch ();
|
DROP FUNCTION internal.pgrst_watch;
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ BEGIN
|
|||||||
FROM
|
FROM
|
||||||
pg_roles AS r
|
pg_roles AS r
|
||||||
WHERE
|
WHERE
|
||||||
r.rolname = NEW.role)) THEN
|
r.rolname = NEW.user_role)) THEN
|
||||||
RAISE foreign_key_violation
|
RAISE foreign_key_violation
|
||||||
USING message = 'Unknown database role: ' || NEW.role;
|
USING message = 'Unknown database role: ' || NEW.user_role;
|
||||||
END IF;
|
END IF;
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
END
|
END
|
||||||
@@ -48,7 +48,7 @@ CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME
|
|||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
ROLE INTO role_name
|
u.user_role INTO role_name
|
||||||
FROM
|
FROM
|
||||||
internal.user AS u
|
internal.user AS u
|
||||||
WHERE
|
WHERE
|
||||||
@@ -96,8 +96,17 @@ BEGIN
|
|||||||
RAISE invalid_parameter_value
|
RAISE invalid_parameter_value
|
||||||
USING message = 'Password must contain at least one special character';
|
USING message = 'Password must contain at least one special character';
|
||||||
ELSE
|
ELSE
|
||||||
INSERT INTO internal.user (username, password_hash)
|
INSERT INTO internal.user (username, password_hash, user_role)
|
||||||
VALUES (register.username, register.pass)
|
SELECT
|
||||||
|
register.username,
|
||||||
|
register.pass,
|
||||||
|
CASE WHEN COUNT(*) = 0 THEN
|
||||||
|
'administrator'
|
||||||
|
ELSE
|
||||||
|
'authenticated_user'
|
||||||
|
END
|
||||||
|
FROM
|
||||||
|
internal.user
|
||||||
RETURNING
|
RETURNING
|
||||||
id INTO user_id;
|
id INTO user_id;
|
||||||
END IF;
|
END IF;
|
||||||
@@ -111,7 +120,7 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
_role NAME;
|
_role NAME;
|
||||||
_user_id UUID;
|
_user_id UUID;
|
||||||
_exp INTEGER;
|
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
internal.user_role (login.username, login.pass) INTO _role;
|
internal.user_role (login.username, login.pass) INTO _role;
|
||||||
@@ -120,12 +129,11 @@ BEGIN
|
|||||||
USING message = 'Invalid username or password';
|
USING message = 'Invalid username or password';
|
||||||
ELSE
|
ELSE
|
||||||
SELECT
|
SELECT
|
||||||
id INTO _user_id
|
u.id INTO _user_id
|
||||||
FROM
|
FROM
|
||||||
internal.user AS u
|
internal.user AS u
|
||||||
WHERE
|
WHERE
|
||||||
u.username = login.username;
|
u.username = login.username;
|
||||||
_exp := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
|
|
||||||
SELECT
|
SELECT
|
||||||
SIGN(JSON_BUILD_OBJECT('role', _role, 'user_id', _user_id, 'username', login.username, 'exp', _exp), CURRENT_SETTING('app.jwt_secret')) INTO token;
|
SIGN(JSON_BUILD_OBJECT('role', _role, 'user_id', _user_id, 'username', login.username, 'exp', _exp), CURRENT_SETTING('app.jwt_secret')) INTO token;
|
||||||
END IF;
|
END IF;
|
||||||
@@ -155,28 +163,28 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.register (TEXT, TEXT) TO anon;
|
GRANT EXECUTE ON FUNCTION api.register TO anon;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.login (TEXT, TEXT) TO anon;
|
GRANT EXECUTE ON FUNCTION api.login TO anon;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.delete_account (TEXT) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION api.delete_account TO authenticated_user;
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP TRIGGER encrypt_pass ON internal.user;
|
DROP TRIGGER encrypt_pass ON internal.user;
|
||||||
|
|
||||||
DROP TRIGGER ensure_user_role_exists ON internal.user;
|
DROP TRIGGER ensure_user_role_exists ON internal.user;
|
||||||
|
|
||||||
DROP FUNCTION api.register (TEXT, TEXT);
|
DROP FUNCTION api.register;
|
||||||
|
|
||||||
DROP FUNCTION api.login (TEXT, TEXT);
|
DROP FUNCTION api.login;
|
||||||
|
|
||||||
DROP FUNCTION api.delete_account (TEXT);
|
DROP FUNCTION api.delete_account;
|
||||||
|
|
||||||
DROP FUNCTION internal.user_role (TEXT, TEXT);
|
DROP FUNCTION internal.user_role;
|
||||||
|
|
||||||
DROP FUNCTION internal.encrypt_pass ();
|
DROP FUNCTION internal.encrypt_pass;
|
||||||
|
|
||||||
DROP FUNCTION internal.check_role_exists ();
|
DROP FUNCTION internal.check_role_exists;
|
||||||
|
|
||||||
DROP EXTENSION pgjwt;
|
DROP EXTENSION pgjwt;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ CREATE VIEW api.user WITH ( security_invoker = ON
|
|||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
username
|
username,
|
||||||
|
created_at,
|
||||||
|
max_number_websites
|
||||||
FROM
|
FROM
|
||||||
internal.user;
|
internal.user;
|
||||||
|
|
||||||
@@ -87,38 +89,46 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
_website_id UUID;
|
_website_id UUID;
|
||||||
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
|
_user_website_count INT := (
|
||||||
|
SELECT
|
||||||
|
COUNT(*)
|
||||||
|
FROM
|
||||||
|
internal.website AS w
|
||||||
|
WHERE
|
||||||
|
w.user_id = _user_id);
|
||||||
|
_user_max_websites_allowed_count INT := (
|
||||||
|
SELECT
|
||||||
|
u.max_number_websites
|
||||||
|
FROM
|
||||||
|
internal.user AS u
|
||||||
|
WHERE
|
||||||
|
id = _user_id);
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO internal.website (content_type, title)
|
IF (_user_website_count + 1 > _user_max_websites_allowed_count) THEN
|
||||||
VALUES (create_website.content_type, create_website.title)
|
RAISE invalid_parameter_value
|
||||||
RETURNING
|
USING message = FORMAT('Limit of %s websites exceeded', _user_max_websites_allowed_count);
|
||||||
id INTO _website_id;
|
END IF;
|
||||||
INSERT INTO internal.settings (website_id)
|
INSERT INTO internal.website (content_type, title)
|
||||||
VALUES (_website_id);
|
VALUES (create_website.content_type, create_website.title)
|
||||||
INSERT INTO internal.header (website_id, logo_text)
|
RETURNING
|
||||||
VALUES (_website_id, 'archtika ' || create_website.content_type);
|
id INTO _website_id;
|
||||||
INSERT INTO internal.home (website_id, main_content)
|
INSERT INTO internal.settings (website_id)
|
||||||
VALUES (_website_id, '## About
|
VALUES (_website_id);
|
||||||
|
INSERT INTO internal.header (website_id, logo_text)
|
||||||
|
VALUES (_website_id, 'archtika ' || create_website.content_type);
|
||||||
|
INSERT INTO internal.home (website_id, main_content)
|
||||||
|
VALUES (_website_id, '## About
|
||||||
|
|
||||||
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates.
|
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates. It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.');
|
||||||
|
INSERT INTO internal.footer (website_id, additional_text)
|
||||||
It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.
|
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
|
||||||
|
website_id := _website_id;
|
||||||
## How it works
|
|
||||||
|
|
||||||
For the backend, PostgreSQL is used in combination with PostgREST to create a RESTful API. JSON web tokens along with row-level security control authentication and authorisation flows.
|
|
||||||
|
|
||||||
The web application uses SvelteKit with SSR (Server Side Rendering) and Svelte version 5, currently in beta.
|
|
||||||
|
|
||||||
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.');
|
|
||||||
INSERT INTO internal.footer (website_id, additional_text)
|
|
||||||
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
|
|
||||||
website_id := _website_id;
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.create_website (VARCHAR(10), VARCHAR(50)) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION api.create_website TO authenticated_user;
|
||||||
|
|
||||||
-- Security invoker only works on views if the user has access to the underlying table
|
-- Security invoker only works on views if the user has access to the underlying table
|
||||||
GRANT SELECT ON internal.user TO authenticated_user;
|
GRANT SELECT ON internal.user TO authenticated_user;
|
||||||
@@ -139,7 +149,7 @@ GRANT SELECT, UPDATE (logo_type, logo_text, logo_image) ON internal.header TO au
|
|||||||
|
|
||||||
GRANT SELECT, UPDATE ON api.header TO authenticated_user;
|
GRANT SELECT, UPDATE ON api.header TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT, UPDATE (main_content) ON internal.home TO authenticated_user;
|
GRANT SELECT, UPDATE (main_content, meta_description) ON internal.home TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT, UPDATE ON api.home TO authenticated_user;
|
GRANT SELECT, UPDATE ON api.home TO authenticated_user;
|
||||||
|
|
||||||
@@ -164,7 +174,7 @@ GRANT SELECT, INSERT (website_id, user_id, permission_level), UPDATE (permission
|
|||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
|
DROP FUNCTION api.create_website;
|
||||||
|
|
||||||
DROP VIEW api.collab;
|
DROP VIEW api.collab;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
|
|||||||
|
|
||||||
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INTEGER, collaborator_permission_level INTEGER DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
|
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INT, collaborator_permission_level INT DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
@@ -63,19 +63,29 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION internal.user_has_website_access TO authenticated_user;
|
||||||
|
|
||||||
CREATE POLICY view_user ON internal.user
|
CREATE POLICY view_user ON internal.user
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (TRUE);
|
USING (TRUE);
|
||||||
|
|
||||||
|
CREATE POLICY update_user ON internal.user
|
||||||
|
FOR UPDATE
|
||||||
|
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator');
|
||||||
|
|
||||||
|
CREATE POLICY delete_user ON internal.user
|
||||||
|
FOR DELETE
|
||||||
|
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator');
|
||||||
|
|
||||||
CREATE POLICY view_websites ON internal.website
|
CREATE POLICY view_websites ON internal.website
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (internal.user_has_website_access (id, 10, raise_error => FALSE));
|
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator'
|
||||||
|
OR internal.user_has_website_access (id, 10, raise_error => FALSE));
|
||||||
|
|
||||||
CREATE POLICY update_website ON internal.website
|
CREATE POLICY update_website ON internal.website
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
USING (internal.user_has_website_access (id, 20));
|
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator'
|
||||||
|
OR internal.user_has_website_access (id, 30));
|
||||||
|
|
||||||
CREATE POLICY delete_website ON internal.website
|
CREATE POLICY delete_website ON internal.website
|
||||||
FOR DELETE
|
FOR DELETE
|
||||||
@@ -180,6 +190,10 @@ CREATE POLICY delete_collaborations ON internal.collab
|
|||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP POLICY view_user ON internal.user;
|
DROP POLICY view_user ON internal.user;
|
||||||
|
|
||||||
|
DROP POLICY update_user ON internal.user;
|
||||||
|
|
||||||
|
DROP POLICY delete_user ON internal.user;
|
||||||
|
|
||||||
DROP POLICY view_websites ON internal.website;
|
DROP POLICY view_websites ON internal.website;
|
||||||
|
|
||||||
DROP POLICY delete_website ON internal.website;
|
DROP POLICY delete_website ON internal.website;
|
||||||
@@ -234,7 +248,7 @@ DROP POLICY update_collaborations ON internal.collab;
|
|||||||
|
|
||||||
DROP POLICY delete_collaborations ON internal.collab;
|
DROP POLICY delete_collaborations ON internal.collab;
|
||||||
|
|
||||||
DROP FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN);
|
DROP FUNCTION internal.user_has_website_access;
|
||||||
|
|
||||||
ALTER TABLE internal.user DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.user DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ DECLARE
|
|||||||
BEGIN
|
BEGIN
|
||||||
IF (NOT EXISTS (
|
IF (NOT EXISTS (
|
||||||
SELECT
|
SELECT
|
||||||
id
|
u.id
|
||||||
FROM
|
FROM
|
||||||
internal.user
|
internal.user AS u
|
||||||
WHERE
|
WHERE
|
||||||
id = _user_id)) THEN
|
u.id = _user_id)) THEN
|
||||||
RETURN COALESCE(NEW, OLD);
|
RETURN COALESCE(NEW, OLD);
|
||||||
END IF;
|
END IF;
|
||||||
IF TG_OP != 'DELETE' THEN
|
IF TG_OP != 'DELETE' THEN
|
||||||
@@ -97,5 +97,5 @@ DROP TRIGGER update_legal_information_last_modified ON internal.legal_informatio
|
|||||||
|
|
||||||
DROP TRIGGER update_collab_last_modified ON internal.collab;
|
DROP TRIGGER update_collab_last_modified ON internal.collab;
|
||||||
|
|
||||||
DROP FUNCTION internal.update_last_modified ();
|
DROP FUNCTION internal.update_last_modified;
|
||||||
|
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ CREATE CONSTRAINT TRIGGER check_user_not_website_owner
|
|||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP TRIGGER check_user_not_website_owner ON internal.collab;
|
DROP TRIGGER check_user_not_website_owner ON internal.collab;
|
||||||
|
|
||||||
DROP FUNCTION internal.check_user_not_website_owner ();
|
DROP FUNCTION internal.check_user_not_website_owner;
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,31 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
|
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
|
||||||
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
|
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
|
||||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
|
||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
_original_filename TEXT := _headers ->> 'x-original-filename';
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'image/svg+xml'];
|
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'image/svg+xml'];
|
||||||
_max_file_size INT := 5 * 1024 * 1024;
|
_max_file_size BIGINT := 5 * 1024 * 1024;
|
||||||
_has_access BOOLEAN;
|
_has_access BOOLEAN;
|
||||||
|
_mimetype TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
_has_access = internal.user_has_website_access (_website_id, 20);
|
_has_access = internal.user_has_website_access (_website_id, 20);
|
||||||
|
_mimetype := CASE WHEN SUBSTRING($1 FROM 1 FOR 8) = '\x89504E470D0A1A0A'::BYTEA THEN
|
||||||
|
'image/png'
|
||||||
|
WHEN SUBSTRING($1 FROM 1 FOR 3) = '\xFFD8FF'::BYTEA THEN
|
||||||
|
'image/jpeg'
|
||||||
|
WHEN SUBSTRING($1 FROM 1 FOR 4) = '\x52494646'::BYTEA
|
||||||
|
AND SUBSTRING($1 FROM 9 FOR 4) = '\x57454250'::BYTEA THEN
|
||||||
|
'image/webp'
|
||||||
|
WHEN SUBSTRING($1 FROM 5 FOR 7) = '\x66747970617669'::BYTEA THEN
|
||||||
|
'image/avif'
|
||||||
|
WHEN SUBSTRING($1 FROM 1 FOR 6) = '\x474946383761'::BYTEA
|
||||||
|
OR SUBSTRING($1 FROM 1 FOR 6) = '\x474946383961'::BYTEA THEN
|
||||||
|
'image/gif'
|
||||||
|
WHEN SUBSTRING($1 FROM 1 FOR 5) = '\x3C3F786D6C'::BYTEA
|
||||||
|
OR SUBSTRING($1 FROM 1 FOR 4) = '\x3C737667'::BYTEA THEN
|
||||||
|
'image/svg+xml'
|
||||||
|
ELSE
|
||||||
|
NULL
|
||||||
|
END;
|
||||||
IF OCTET_LENGTH($1) = 0 THEN
|
IF OCTET_LENGTH($1) = 0 THEN
|
||||||
RAISE invalid_parameter_value
|
RAISE invalid_parameter_value
|
||||||
USING message = 'No file data was provided';
|
USING message = 'No file data was provided';
|
||||||
@@ -21,10 +39,10 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
UNNEST(_allowed_mimetypes))) THEN
|
UNNEST(_allowed_mimetypes))) THEN
|
||||||
RAISE invalid_parameter_value
|
RAISE invalid_parameter_value
|
||||||
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
|
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp, avif, gif, svg';
|
||||||
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
|
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
|
||||||
RAISE program_limit_exceeded
|
RAISE program_limit_exceeded
|
||||||
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
USING message = FORMAT('File size exceeds the maximum limit of %s', PG_SIZE_PRETTY(_max_file_size));
|
||||||
ELSE
|
ELSE
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
VALUES (_website_id, $1, _mimetype, _original_filename)
|
||||||
@@ -56,7 +74,7 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
m.blob
|
m.blob
|
||||||
FROM
|
FROM
|
||||||
internal.media m
|
internal.media AS m
|
||||||
WHERE
|
WHERE
|
||||||
m.id = retrieve_file.id INTO _blob;
|
m.id = retrieve_file.id INTO _blob;
|
||||||
IF FOUND THEN
|
IF FOUND THEN
|
||||||
@@ -70,16 +88,16 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION api.upload_file TO authenticated_user;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO anon;
|
GRANT EXECUTE ON FUNCTION api.retrieve_file TO anon;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION api.retrieve_file TO authenticated_user;
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP FUNCTION api.upload_file (BYTEA);
|
DROP FUNCTION api.upload_file;
|
||||||
|
|
||||||
DROP FUNCTION api.retrieve_file (UUID);
|
DROP FUNCTION api.retrieve_file;
|
||||||
|
|
||||||
DROP DOMAIN "*/*";
|
DROP DOMAIN "*/*";
|
||||||
|
|
||||||
|
|||||||
@@ -13,102 +13,6 @@ CREATE TABLE internal.change_log (
|
|||||||
new_value HSTORE
|
new_value HSTORE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE FUNCTION internal.track_changes ()
|
|
||||||
RETURNS TRIGGER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
_website_id UUID;
|
|
||||||
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
|
||||||
BEGIN
|
|
||||||
IF (NOT EXISTS (
|
|
||||||
SELECT
|
|
||||||
id
|
|
||||||
FROM
|
|
||||||
internal.user
|
|
||||||
WHERE
|
|
||||||
id = _user_id) OR (to_jsonb (OLD.*) - 'last_modified_at' - 'last_modified_by') = (to_jsonb (NEW.*) - 'last_modified_at' - 'last_modified_by')) THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
IF TG_TABLE_NAME = 'website' THEN
|
|
||||||
_website_id := NEW.id;
|
|
||||||
ELSE
|
|
||||||
_website_id := COALESCE(NEW.website_id, OLD.website_id);
|
|
||||||
END IF;
|
|
||||||
IF TG_OP = 'INSERT' THEN
|
|
||||||
INSERT INTO internal.change_log (website_id, table_name, operation, new_value)
|
|
||||||
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (NEW));
|
|
||||||
ELSIF (TG_OP = 'UPDATE'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT
|
|
||||||
id
|
|
||||||
FROM
|
|
||||||
internal.website
|
|
||||||
WHERE
|
|
||||||
id = _website_id)) THEN
|
|
||||||
INSERT INTO internal.change_log (website_id, table_name, operation, old_value, new_value)
|
|
||||||
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (OLD) - HSTORE (NEW), HSTORE (NEW) - HSTORE (OLD));
|
|
||||||
ELSIF (TG_OP = 'DELETE'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT
|
|
||||||
id
|
|
||||||
FROM
|
|
||||||
internal.website
|
|
||||||
WHERE
|
|
||||||
id = _website_id)) THEN
|
|
||||||
INSERT INTO internal.change_log (website_id, table_name, operation, old_value)
|
|
||||||
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (OLD));
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE TRIGGER website_track_changes
|
|
||||||
AFTER UPDATE ON internal.website
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER settings_track_changes
|
|
||||||
AFTER UPDATE ON internal.settings
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER header_track_changes
|
|
||||||
AFTER UPDATE ON internal.header
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER home_track_changes
|
|
||||||
AFTER UPDATE ON internal.home
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER article_track_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.article
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER docs_category_track_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER footer_track_changes
|
|
||||||
AFTER UPDATE ON internal.footer
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER legal_information_track_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE TRIGGER collab_track_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
|
||||||
|
|
||||||
CREATE VIEW api.change_log WITH ( security_invoker = ON
|
CREATE VIEW api.change_log WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
@@ -120,26 +24,141 @@ GRANT SELECT ON internal.change_log TO authenticated_user;
|
|||||||
|
|
||||||
GRANT SELECT ON api.change_log TO authenticated_user;
|
GRANT SELECT ON api.change_log TO authenticated_user;
|
||||||
|
|
||||||
|
ALTER TABLE internal.change_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY view_change_log ON internal.change_log
|
||||||
|
FOR SELECT
|
||||||
|
USING (internal.user_has_website_access (website_id, 10));
|
||||||
|
|
||||||
|
CREATE FUNCTION internal.track_changes ()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_website_id UUID;
|
||||||
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
|
_new_value HSTORE;
|
||||||
|
BEGIN
|
||||||
|
IF (NOT EXISTS (
|
||||||
|
SELECT
|
||||||
|
u.id
|
||||||
|
FROM
|
||||||
|
internal.user AS u
|
||||||
|
WHERE
|
||||||
|
u.id = _user_id) OR REGEXP_REPLACE((to_jsonb (OLD.*) - 'last_modified_at' - 'last_modified_by')::TEXT, '\r\n|\r', '\n', 'g') = REGEXP_REPLACE((to_jsonb (NEW.*) - 'last_modified_at' - 'last_modified_by')::TEXT, '\r\n|\r', '\n', 'g')) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
IF TG_TABLE_NAME = 'website' THEN
|
||||||
|
_website_id := NEW.id;
|
||||||
|
ELSE
|
||||||
|
_website_id := COALESCE(NEW.website_id, OLD.website_id);
|
||||||
|
END IF;
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
_new_value := CASE WHEN TG_TABLE_NAME = 'media' THEN
|
||||||
|
HSTORE (NEW) - 'blob'::TEXT
|
||||||
|
ELSE
|
||||||
|
HSTORE (NEW)
|
||||||
|
END;
|
||||||
|
INSERT INTO internal.change_log (website_id, table_name, operation, new_value)
|
||||||
|
VALUES (_website_id, TG_TABLE_NAME, TG_OP, _new_value);
|
||||||
|
ELSIF (TG_OP = 'UPDATE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT
|
||||||
|
w.id
|
||||||
|
FROM
|
||||||
|
internal.website AS w
|
||||||
|
WHERE
|
||||||
|
w.id = _website_id)) THEN
|
||||||
|
INSERT INTO internal.change_log (website_id, table_name, operation, old_value, new_value)
|
||||||
|
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (OLD) - HSTORE (NEW), HSTORE (NEW) - HSTORE (OLD));
|
||||||
|
ELSIF (TG_OP = 'DELETE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT
|
||||||
|
w.id
|
||||||
|
FROM
|
||||||
|
internal.website AS w
|
||||||
|
WHERE
|
||||||
|
w.id = _website_id)) THEN
|
||||||
|
INSERT INTO internal.change_log (website_id, table_name, operation, old_value)
|
||||||
|
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (OLD));
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_website
|
||||||
|
AFTER UPDATE ON internal.website
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_media
|
||||||
|
AFTER INSERT ON internal.media
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_settings
|
||||||
|
AFTER UPDATE ON internal.settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_header
|
||||||
|
AFTER UPDATE ON internal.header
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_home
|
||||||
|
AFTER UPDATE ON internal.home
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_article
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.article
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_docs_category
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_footer
|
||||||
|
AFTER UPDATE ON internal.footer
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_legal_information
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER track_changes_collab
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP TRIGGER website_track_changes ON internal.website;
|
DROP TRIGGER track_changes_website ON internal.website;
|
||||||
|
|
||||||
DROP TRIGGER settings_track_changes ON internal.settings;
|
DROP TRIGGER track_changes_media ON internal.media;
|
||||||
|
|
||||||
DROP TRIGGER header_track_changes ON internal.header;
|
DROP TRIGGER track_changes_settings ON internal.settings;
|
||||||
|
|
||||||
DROP TRIGGER home_track_changes ON internal.home;
|
DROP TRIGGER track_changes_header ON internal.header;
|
||||||
|
|
||||||
DROP TRIGGER article_track_changes ON internal.article;
|
DROP TRIGGER track_changes_home ON internal.home;
|
||||||
|
|
||||||
DROP TRIGGER docs_category_track_changes ON internal.docs_category;
|
DROP TRIGGER track_changes_article ON internal.article;
|
||||||
|
|
||||||
DROP TRIGGER footer_track_changes ON internal.footer;
|
DROP TRIGGER track_changes_docs_category ON internal.docs_category;
|
||||||
|
|
||||||
DROP TRIGGER legal_information_track_changes ON internal.legal_information;
|
DROP TRIGGER track_changes_footer ON internal.footer;
|
||||||
|
|
||||||
DROP TRIGGER collab_track_changes ON internal.collab;
|
DROP TRIGGER track_changes_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
DROP FUNCTION internal.track_changes ();
|
DROP TRIGGER track_changes_collab ON internal.collab;
|
||||||
|
|
||||||
|
DROP FUNCTION internal.track_changes;
|
||||||
|
|
||||||
DROP VIEW api.change_log;
|
DROP VIEW api.change_log;
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ CREATE TRIGGER update_domain_prefix_last_modified
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.update_last_modified ();
|
EXECUTE FUNCTION internal.update_last_modified ();
|
||||||
|
|
||||||
CREATE TRIGGER domain_prefix_track_changes
|
CREATE TRIGGER track_changes_domain_prefix
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
|
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP TRIGGER domain_prefix_track_changes ON internal.domain_prefix;
|
DROP TRIGGER track_changes_domain_prefix ON internal.domain_prefix;
|
||||||
|
|
||||||
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;
|
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;
|
||||||
|
|
||||||
|
|||||||
185
rest-api/db/migrations/20241006165029_administrator.sql
Normal file
185
rest-api/db/migrations/20241006165029_administrator.sql
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
-- migrate:up
|
||||||
|
CREATE FUNCTION api.user_websites_storage_size ()
|
||||||
|
RETURNS TABLE (
|
||||||
|
website_id UUID,
|
||||||
|
website_title VARCHAR(50),
|
||||||
|
storage_size_bytes BIGINT,
|
||||||
|
storage_size_pretty TEXT,
|
||||||
|
max_storage_bytes BIGINT,
|
||||||
|
max_storage_pretty TEXT,
|
||||||
|
diff_storage_pretty TEXT
|
||||||
|
)
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
|
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
|
||||||
|
_query TEXT;
|
||||||
|
_union_queries TEXT := '';
|
||||||
|
BEGIN
|
||||||
|
FOR i IN 1..ARRAY_LENGTH(_tables, 1)
|
||||||
|
LOOP
|
||||||
|
_union_queries := _union_queries || FORMAT('
|
||||||
|
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = w.id', _tables[i]);
|
||||||
|
IF i < ARRAY_LENGTH(_tables, 1) THEN
|
||||||
|
_union_queries := _union_queries || ' UNION ALL ';
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
_query := FORMAT('
|
||||||
|
SELECT
|
||||||
|
w.id AS website_id,
|
||||||
|
w.title AS website_title,
|
||||||
|
COALESCE(SUM(sizes.total_size), 0)::BIGINT AS storage_size_bytes,
|
||||||
|
PG_SIZE_PRETTY(COALESCE(SUM(sizes.total_size), 0)) AS storage_size_pretty,
|
||||||
|
(w.max_storage_size::BIGINT * 1024 * 1024) AS max_storage_bytes,
|
||||||
|
PG_SIZE_PRETTY(w.max_storage_size::BIGINT * 1024 * 1024) AS max_storage_pretty,
|
||||||
|
PG_SIZE_PRETTY((w.max_storage_size::BIGINT * 1024 * 1024) - COALESCE(SUM(sizes.total_size), 0)) AS diff_storage_pretty
|
||||||
|
FROM
|
||||||
|
internal.website AS w
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
%s
|
||||||
|
) AS sizes(total_size) ON TRUE
|
||||||
|
WHERE
|
||||||
|
w.user_id = $1
|
||||||
|
GROUP BY
|
||||||
|
w.id,
|
||||||
|
w.title
|
||||||
|
ORDER BY
|
||||||
|
storage_size_bytes DESC', _union_queries);
|
||||||
|
RETURN QUERY EXECUTE _query
|
||||||
|
USING _user_id;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION api.user_websites_storage_size TO authenticated_user;
|
||||||
|
|
||||||
|
CREATE FUNCTION internal.prevent_website_storage_size_excess ()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_website_id UUID := NEW.website_id;
|
||||||
|
_current_size BIGINT;
|
||||||
|
_size_difference BIGINT := PG_COLUMN_SIZE(NEW) - COALESCE(PG_COLUMN_SIZE(OLD), 0);
|
||||||
|
_max_storage_mb INT := (
|
||||||
|
SELECT
|
||||||
|
w.max_storage_size
|
||||||
|
FROM
|
||||||
|
internal.website AS w
|
||||||
|
WHERE
|
||||||
|
w.id = _website_id);
|
||||||
|
_max_storage_bytes BIGINT := _max_storage_mb::BIGINT * 1024 * 1024;
|
||||||
|
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
|
||||||
|
_union_queries TEXT := '';
|
||||||
|
_query TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR i IN 1..ARRAY_LENGTH(_tables, 1)
|
||||||
|
LOOP
|
||||||
|
_union_queries := _union_queries || FORMAT('
|
||||||
|
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = $1', _tables[i]);
|
||||||
|
IF i < ARRAY_LENGTH(_tables, 1) THEN
|
||||||
|
_union_queries := _union_queries || ' UNION ALL ';
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
_query := FORMAT('
|
||||||
|
SELECT COALESCE(SUM(sizes.total_size), 0)::BIGINT
|
||||||
|
FROM (%s) AS sizes(total_size)', _union_queries);
|
||||||
|
EXECUTE _query INTO _current_size
|
||||||
|
USING _website_id;
|
||||||
|
IF (_current_size + _size_difference) > _max_storage_bytes THEN
|
||||||
|
RAISE program_limit_exceeded
|
||||||
|
USING message = FORMAT('Storage limit exceeded. Current size: %s, Max size: %s', PG_SIZE_PRETTY(_current_size), PG_SIZE_PRETTY(_max_storage_bytes));
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_article
|
||||||
|
BEFORE INSERT OR UPDATE ON internal.article
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_collab
|
||||||
|
BEFORE INSERT OR UPDATE ON internal.collab
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_docs_category
|
||||||
|
BEFORE INSERT OR UPDATE ON internal.docs_category
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_domain_prefix
|
||||||
|
BEFORE INSERT OR UPDATE ON internal.domain_prefix
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_footer
|
||||||
|
BEFORE UPDATE ON internal.footer
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_header
|
||||||
|
BEFORE UPDATE ON internal.header
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_home
|
||||||
|
BEFORE UPDATE ON internal.home
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_legal_information
|
||||||
|
BEFORE INSERT OR UPDATE ON internal.legal_information
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_media
|
||||||
|
BEFORE INSERT ON internal.media
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _prevent_storage_excess_settings
|
||||||
|
BEFORE UPDATE ON internal.settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
|
||||||
|
|
||||||
|
GRANT UPDATE (max_storage_size) ON internal.website TO administrator;
|
||||||
|
|
||||||
|
GRANT UPDATE, DELETE ON internal.user TO administrator;
|
||||||
|
|
||||||
|
GRANT UPDATE, DELETE ON api.user TO administrator;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP FUNCTION api.user_websites_storage_size;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_article ON internal.article;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_collab ON internal.collab;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_docs_category ON internal.docs_category;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_domain_prefix ON internal.domain_prefix;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_footer ON internal.footer;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_header ON internal.header;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_home ON internal.home;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_media ON internal.media;
|
||||||
|
|
||||||
|
DROP TRIGGER _prevent_storage_excess_settings ON internal.settings;
|
||||||
|
|
||||||
|
DROP FUNCTION internal.prevent_website_storage_size_excess;
|
||||||
|
|
||||||
|
REVOKE UPDATE (max_storage_size) ON internal.website FROM administrator;
|
||||||
|
|
||||||
|
REVOKE UPDATE, DELETE ON internal.user FROM administrator;
|
||||||
|
|
||||||
|
REVOKE UPDATE, DELETE ON api.user FROM administrator;
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- migrate:up
|
||||||
|
CREATE FUNCTION internal.cleanup_filesystem ()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_website_id UUID;
|
||||||
|
_domain_prefix VARCHAR(16);
|
||||||
|
_base_path CONSTANT TEXT := '/var/www/archtika-websites/';
|
||||||
|
_preview_path TEXT;
|
||||||
|
_prod_path TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF TG_TABLE_NAME = 'website' THEN
|
||||||
|
_website_id := OLD.id;
|
||||||
|
ELSE
|
||||||
|
_website_id := OLD.website_id;
|
||||||
|
END IF;
|
||||||
|
SELECT
|
||||||
|
d.prefix INTO _domain_prefix
|
||||||
|
FROM
|
||||||
|
internal.domain_prefix d
|
||||||
|
WHERE
|
||||||
|
d.website_id = _website_id;
|
||||||
|
_preview_path := _base_path || 'previews/' || _website_id;
|
||||||
|
_prod_path := _base_path || COALESCE(_domain_prefix, _website_id::TEXT);
|
||||||
|
IF TG_TABLE_NAME = 'website' THEN
|
||||||
|
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _preview_path);
|
||||||
|
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _prod_path);
|
||||||
|
ELSE
|
||||||
|
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _preview_path);
|
||||||
|
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _prod_path);
|
||||||
|
END IF;
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER _cleanup_filesystem_website
|
||||||
|
BEFORE DELETE ON internal.website
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.cleanup_filesystem ();
|
||||||
|
|
||||||
|
CREATE TRIGGER _cleanup_filesystem_legal_information
|
||||||
|
BEFORE DELETE ON internal.legal_information
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.cleanup_filesystem ();
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP TRIGGER _cleanup_filesystem_website ON internal.website;
|
||||||
|
|
||||||
|
DROP TRIGGER _cleanup_filesystem_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
|
DROP FUNCTION internal.cleanup_filesystem;
|
||||||
|
|
||||||
46
web-app/package-lock.json
generated
46
web-app/package-lock.json
generated
@@ -8,18 +8,19 @@
|
|||||||
"name": "web-app",
|
"name": "web-app",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-diff": "1.3.0",
|
"diff-match-patch": "1.0.5",
|
||||||
"highlight.js": "11.10.0",
|
"highlight.js": "11.10.0",
|
||||||
"isomorphic-dompurify": "2.15.0",
|
"isomorphic-dompurify": "2.15.0",
|
||||||
"marked": "14.1.2",
|
"marked": "14.1.2",
|
||||||
"marked-highlight": "2.1.4"
|
"marked-highlight": "2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.46.0",
|
"@playwright/test": "1.47.0",
|
||||||
"@sveltejs/adapter-auto": "3.2.5",
|
"@sveltejs/adapter-auto": "3.2.5",
|
||||||
"@sveltejs/adapter-node": "5.2.3",
|
"@sveltejs/adapter-node": "5.2.3",
|
||||||
"@sveltejs/kit": "2.5.28",
|
"@sveltejs/kit": "2.5.28",
|
||||||
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
|
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
|
||||||
|
"@types/diff-match-patch": "1.0.36",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"@types/eslint__js": "8.42.3",
|
"@types/eslint__js": "8.42.3",
|
||||||
"@types/eslint-config-prettier": "6.11.3",
|
"@types/eslint-config-prettier": "6.11.3",
|
||||||
@@ -764,13 +765,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.46.0",
|
"version": "1.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz",
|
||||||
"integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==",
|
"integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.46.0"
|
"playwright": "1.47.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -1215,6 +1216,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/diff-match-patch": {
|
||||||
|
"version": "1.0.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
||||||
|
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/dompurify": {
|
"node_modules/@types/dompurify": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
@@ -2044,6 +2052,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/diff-match-patch": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.1.6",
|
"version": "3.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
|
||||||
@@ -2485,12 +2499,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||||
@@ -3647,13 +3655,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.46.0",
|
"version": "1.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz",
|
||||||
"integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==",
|
"integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.46.0"
|
"playwright-core": "1.47.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3666,9 +3674,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.46.0",
|
"version": "1.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz",
|
||||||
"integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==",
|
"integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -14,11 +14,12 @@
|
|||||||
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal"
|
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.46.0",
|
"@playwright/test": "1.47.0",
|
||||||
"@sveltejs/adapter-auto": "3.2.5",
|
"@sveltejs/adapter-auto": "3.2.5",
|
||||||
"@sveltejs/adapter-node": "5.2.3",
|
"@sveltejs/adapter-node": "5.2.3",
|
||||||
"@sveltejs/kit": "2.5.28",
|
"@sveltejs/kit": "2.5.28",
|
||||||
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
|
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
|
||||||
|
"@types/diff-match-patch": "1.0.36",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"@types/eslint__js": "8.42.3",
|
"@types/eslint__js": "8.42.3",
|
||||||
"@types/eslint-config-prettier": "6.11.3",
|
"@types/eslint-config-prettier": "6.11.3",
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-diff": "1.3.0",
|
"diff-match-patch": "1.0.5",
|
||||||
"highlight.js": "11.10.0",
|
"highlight.js": "11.10.0",
|
||||||
"isomorphic-dompurify": "2.15.0",
|
"isomorphic-dompurify": "2.15.0",
|
||||||
"marked": "14.1.2",
|
"marked": "14.1.2",
|
||||||
|
|||||||
@@ -9,15 +9,36 @@ const config: PlaywrightTestConfig = {
|
|||||||
baseURL: "http://localhost:4173",
|
baseURL: "http://localhost:4173",
|
||||||
video: "retain-on-failure"
|
video: "retain-on-failure"
|
||||||
},
|
},
|
||||||
testDir: "tests",
|
testDir: "./tests",
|
||||||
testMatch: /(.+\.)?(test|spec)\.ts/,
|
testMatch: /(.+\.)?(test|spec)\.ts/,
|
||||||
retries: 3,
|
// https://github.com/NixOS/nixpkgs/issues/288826
|
||||||
// Firefox and Webkit are not packaged yet, see https://github.com/NixOS/nixpkgs/issues/288826
|
|
||||||
projects: [
|
projects: [
|
||||||
|
{
|
||||||
|
name: "Register users",
|
||||||
|
testMatch: /global-setup\.ts/,
|
||||||
|
teardown: "Delete users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete users",
|
||||||
|
testMatch: /global-teardown\.ts/
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Chromium",
|
name: "Chromium",
|
||||||
use: { ...devices["Desktop Chrome"] }
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
dependencies: ["Register users"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
dependencies: ["Register users"]
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
Upstream bug "Error: browserContext.newPage: Target page, context or browser has been closed"
|
||||||
|
{
|
||||||
|
name: "Webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
dependencies: ["Register users"]
|
||||||
|
} */
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
|
import type { User } from "$lib/db-schema";
|
||||||
|
|
||||||
export const handle = async ({ event, resolve }) => {
|
export const handle = async ({ event, resolve }) => {
|
||||||
if (!event.url.pathname.startsWith("/api/")) {
|
if (!event.url.pathname.startsWith("/api/")) {
|
||||||
@@ -20,6 +21,13 @@ export const handle = async ({ event, resolve }) => {
|
|||||||
throw redirect(303, "/");
|
throw redirect(303, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(userData.data as User).user_role !== "administrator" &&
|
||||||
|
event.url.pathname.includes("/manage")
|
||||||
|
) {
|
||||||
|
throw redirect(303, "/");
|
||||||
|
}
|
||||||
|
|
||||||
event.locals.user = userData.data;
|
event.locals.user = userData.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
margin-inline-start: -2rem;
|
margin-inline-start: -2rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: var(--border-primary);
|
border: var(--border-primary);
|
||||||
border-width: 0.125rem;
|
border-width: 0.25rem;
|
||||||
border-block-start-color: var(--color-accent);
|
border-block-start-color: var(--color-accent);
|
||||||
animation: spinner 500ms linear infinite;
|
animation: spinner 500ms linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { deserialize, applyAction } from "$app/forms";
|
import { deserialize, applyAction } from "$app/forms";
|
||||||
import { textareaScrollTop, previewContent } from "$lib/runes.svelte";
|
import { textareaScrollTop, previewContent } from "$lib/runes.svelte";
|
||||||
|
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
|
||||||
|
import { LOADING_DELAY } from "$lib/utils";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiPrefix,
|
apiPrefix,
|
||||||
@@ -10,6 +12,8 @@
|
|||||||
}: { apiPrefix: string; label: string; name: string; content: string } = $props();
|
}: { apiPrefix: string; label: string; name: string; content: string } = $props();
|
||||||
|
|
||||||
let mainContentTextarea: HTMLTextAreaElement;
|
let mainContentTextarea: HTMLTextAreaElement;
|
||||||
|
let loadingDelay: number;
|
||||||
|
let pasting = $state(false);
|
||||||
|
|
||||||
const updateScrollPercentage = () => {
|
const updateScrollPercentage = () => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
|
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
|
||||||
@@ -28,6 +32,8 @@
|
|||||||
|
|
||||||
if (!fileObject) return;
|
if (!fileObject) return;
|
||||||
|
|
||||||
|
loadingDelay = window.setTimeout(() => (pasting = true), LOADING_DELAY);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", fileObject);
|
formData.append("file", fileObject);
|
||||||
|
|
||||||
@@ -46,23 +52,37 @@
|
|||||||
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
|
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
|
||||||
|
|
||||||
const target = event.target as HTMLTextAreaElement;
|
const target = event.target as HTMLTextAreaElement;
|
||||||
|
const markdownToInsert = ``;
|
||||||
|
const cursorPosition = target.selectionStart;
|
||||||
const newContent =
|
const newContent =
|
||||||
target.value.slice(0, target.selectionStart) +
|
target.value.slice(0, cursorPosition) +
|
||||||
`` +
|
markdownToInsert +
|
||||||
target.value.slice(target.selectionStart);
|
target.value.slice(cursorPosition);
|
||||||
|
|
||||||
|
target.value = newContent;
|
||||||
previewContent.value = newContent;
|
previewContent.value = newContent;
|
||||||
} else {
|
|
||||||
return;
|
const newCursorPosition = cursorPosition + markdownToInsert.length;
|
||||||
|
target.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||||
|
target.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(loadingDelay);
|
||||||
|
pasting = false;
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if pasting}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
{label}:
|
{label}:
|
||||||
<textarea
|
<textarea
|
||||||
{name}
|
{name}
|
||||||
rows="20"
|
rows="20"
|
||||||
|
maxlength="200000"
|
||||||
bind:value={previewContent.value}
|
bind:value={previewContent.value}
|
||||||
bind:this={mainContentTextarea}
|
bind:this={mainContentTextarea}
|
||||||
onscroll={updateScrollPercentage}
|
onscroll={updateScrollPercentage}
|
||||||
|
|||||||
@@ -51,8 +51,7 @@
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
border: var(--border-primary);
|
border: var(--border-primary);
|
||||||
inline-size: var(--modal-width);
|
inline-size: min(var(--modal-width), 100%);
|
||||||
max-inline-size: 100%;
|
|
||||||
max-block-size: calc(100vh - var(--space-m));
|
max-block-size: calc(100vh - var(--space-m));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|||||||
82
web-app/src/lib/components/Pagination.svelte
Normal file
82
web-app/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { PAGINATION_MAX_ITEMS } from "$lib/utils";
|
||||||
|
|
||||||
|
const { commonFilters = [], resultCount }: { commonFilters?: string[]; resultCount: number } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
{#snippet commonFilterInputs()}
|
||||||
|
{#each commonFilters as filter}
|
||||||
|
<input type="hidden" name={filter} value={$page.url.searchParams.get(filter)} />
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
<p>
|
||||||
|
{$page.url.searchParams.get("page") ?? 1} / {Math.max(
|
||||||
|
Math.ceil(resultCount / PAGINATION_MAX_ITEMS),
|
||||||
|
1
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<form method="GET">
|
||||||
|
<input type="hidden" name="page" value={1} />
|
||||||
|
{@render commonFilterInputs()}
|
||||||
|
<button type="submit" disabled={($page.url.searchParams.get("page") ?? "1") === "1"}
|
||||||
|
>First</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<form method="GET">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="page"
|
||||||
|
value={Math.max(1, Number.parseInt($page.url.searchParams.get("page") ?? "1") - 1)}
|
||||||
|
/>
|
||||||
|
{@render commonFilterInputs()}
|
||||||
|
<button type="submit" disabled={($page.url.searchParams.get("page") ?? "1") === "1"}
|
||||||
|
>Previous</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<form method="GET">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="page"
|
||||||
|
value={Math.min(
|
||||||
|
Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1),
|
||||||
|
Number.parseInt($page.url.searchParams.get("page") ?? "1") + 1
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{@render commonFilterInputs()}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={($page.url.searchParams.get("page") ?? "1") ===
|
||||||
|
Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1).toString()}>Next</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<form method="GET">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="page"
|
||||||
|
value={Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1)}
|
||||||
|
/>
|
||||||
|
{@render commonFilterInputs()}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={($page.url.searchParams.get("page") ?? "1") ===
|
||||||
|
Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1).toString()}>Last</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination > form:first-of-type {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,11 +3,39 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if success}
|
{#if success}
|
||||||
<p class="toast success">{message}</p>
|
<p class="toast success">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if success === false}
|
{#if success === false}
|
||||||
<p class="toast error">{message}</p>
|
<p class="toast error">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
const scrollHeight = previewElement.scrollHeight - previewElement.clientHeight;
|
const scrollHeight = previewElement.scrollHeight - previewElement.clientHeight;
|
||||||
previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
|
previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
"settings",
|
||||||
|
"articles",
|
||||||
|
"categories",
|
||||||
|
"collaborators",
|
||||||
|
"legal-information",
|
||||||
|
"publish",
|
||||||
|
"logs"
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input type="checkbox" id="toggle-mobile-preview" hidden />
|
<input type="checkbox" id="toggle-mobile-preview" hidden />
|
||||||
@@ -34,27 +44,17 @@
|
|||||||
|
|
||||||
<nav class="operations__nav">
|
<nav class="operations__nav">
|
||||||
<ul class="unpadded">
|
<ul class="unpadded">
|
||||||
<li>
|
{#each tabs.filter((tab) => (tab !== "categories" && contentType === "Blog") || contentType === "Docs") as tab}
|
||||||
<a href="/website/{id}">Settings</a>
|
<li>
|
||||||
</li>
|
<a
|
||||||
<li>
|
href="/website/{id}{tab === 'settings' ? '' : `/${tab}`}"
|
||||||
<a href="/website/{id}/articles">Articles</a>
|
class:active={tab === "settings"
|
||||||
</li>
|
? $page.url.pathname === `/website/${id}`
|
||||||
{#if contentType === "Docs"}
|
: $page.url.pathname.includes(tab)}
|
||||||
<a href="/website/{id}/categories">Categories</a>
|
>{(tab.charAt(0).toUpperCase() + tab.slice(1)).replace("-", " ") || "Settings"}</a
|
||||||
{/if}
|
>
|
||||||
<li>
|
</li>
|
||||||
<a href="/website/{id}/collaborators">Collaborators</a>
|
{/each}
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/website/{id}/legal-information">Legal information</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/website/{id}/publish">Publish</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/website/{id}/logs">Logs</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -117,6 +117,11 @@
|
|||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
text-underline-offset: 0.375rem;
|
||||||
|
text-decoration-thickness: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
label[for="toggle-mobile-preview"] {
|
label[for="toggle-mobile-preview"] {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface Article {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
last_modified_at: Date;
|
last_modified_at: Date;
|
||||||
last_modified_by: string | null;
|
last_modified_by: string | null;
|
||||||
title_description_search: any | null;
|
|
||||||
}
|
}
|
||||||
export interface ArticleInput {
|
export interface ArticleInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -44,7 +43,6 @@ export interface ArticleInput {
|
|||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
last_modified_at?: Date;
|
last_modified_at?: Date;
|
||||||
last_modified_by?: string | null;
|
last_modified_by?: string | null;
|
||||||
title_description_search?: any | null;
|
|
||||||
}
|
}
|
||||||
const article = {
|
const article = {
|
||||||
tableName: "article",
|
tableName: "article",
|
||||||
@@ -62,8 +60,7 @@ const article = {
|
|||||||
"article_weight",
|
"article_weight",
|
||||||
"created_at",
|
"created_at",
|
||||||
"last_modified_at",
|
"last_modified_at",
|
||||||
"last_modified_by",
|
"last_modified_by"
|
||||||
"title_description_search"
|
|
||||||
],
|
],
|
||||||
requiredForInsert: ["website_id", "title"],
|
requiredForInsert: ["website_id", "title"],
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
@@ -302,18 +299,26 @@ const header = {
|
|||||||
export interface Home {
|
export interface Home {
|
||||||
website_id: string;
|
website_id: string;
|
||||||
main_content: string;
|
main_content: string;
|
||||||
|
meta_description: string | null;
|
||||||
last_modified_at: Date;
|
last_modified_at: Date;
|
||||||
last_modified_by: string | null;
|
last_modified_by: string | null;
|
||||||
}
|
}
|
||||||
export interface HomeInput {
|
export interface HomeInput {
|
||||||
website_id: string;
|
website_id: string;
|
||||||
main_content: string;
|
main_content: string;
|
||||||
|
meta_description?: string | null;
|
||||||
last_modified_at?: Date;
|
last_modified_at?: Date;
|
||||||
last_modified_by?: string | null;
|
last_modified_by?: string | null;
|
||||||
}
|
}
|
||||||
const home = {
|
const home = {
|
||||||
tableName: "home",
|
tableName: "home",
|
||||||
columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"],
|
columns: [
|
||||||
|
"website_id",
|
||||||
|
"main_content",
|
||||||
|
"meta_description",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by"
|
||||||
|
],
|
||||||
requiredForInsert: ["website_id", "main_content"],
|
requiredForInsert: ["website_id", "main_content"],
|
||||||
primaryKey: "website_id",
|
primaryKey: "website_id",
|
||||||
foreignKeys: {
|
foreignKeys: {
|
||||||
@@ -433,19 +438,21 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
role: string;
|
user_role: string;
|
||||||
|
max_number_websites: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
export interface UserInput {
|
export interface UserInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
role?: string;
|
user_role?: string;
|
||||||
|
max_number_websites?: number;
|
||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
}
|
}
|
||||||
const user = {
|
const user = {
|
||||||
tableName: "user",
|
tableName: "user",
|
||||||
columns: ["id", "username", "password_hash", "role", "created_at"],
|
columns: ["id", "username", "password_hash", "user_role", "max_number_websites", "created_at"],
|
||||||
requiredForInsert: ["username", "password_hash"],
|
requiredForInsert: ["username", "password_hash"],
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKeys: {},
|
foreignKeys: {},
|
||||||
@@ -459,22 +466,22 @@ export interface Website {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
max_storage_size: number;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
last_modified_at: Date;
|
last_modified_at: Date;
|
||||||
last_modified_by: string | null;
|
last_modified_by: string | null;
|
||||||
title_search: any | null;
|
|
||||||
}
|
}
|
||||||
export interface WebsiteInput {
|
export interface WebsiteInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
max_storage_size?: number;
|
||||||
is_published?: boolean;
|
is_published?: boolean;
|
||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
last_modified_at?: Date;
|
last_modified_at?: Date;
|
||||||
last_modified_by?: string | null;
|
last_modified_by?: string | null;
|
||||||
title_search?: any | null;
|
|
||||||
}
|
}
|
||||||
const website = {
|
const website = {
|
||||||
tableName: "website",
|
tableName: "website",
|
||||||
@@ -483,11 +490,11 @@ const website = {
|
|||||||
"user_id",
|
"user_id",
|
||||||
"content_type",
|
"content_type",
|
||||||
"title",
|
"title",
|
||||||
|
"max_storage_size",
|
||||||
"is_published",
|
"is_published",
|
||||||
"created_at",
|
"created_at",
|
||||||
"last_modified_at",
|
"last_modified_at",
|
||||||
"last_modified_by",
|
"last_modified_by"
|
||||||
"title_search"
|
|
||||||
],
|
],
|
||||||
requiredForInsert: ["content_type", "title"],
|
requiredForInsert: ["content_type", "title"],
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
const {
|
const {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
article,
|
article,
|
||||||
apiUrl
|
apiUrl,
|
||||||
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
|
websiteUrl
|
||||||
|
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string; websiteUrl: string } =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head
|
<Head
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
{apiUrl}
|
{apiUrl}
|
||||||
title={article.title}
|
title={article.title}
|
||||||
metaDescription={article.meta_description}
|
metaDescription={article.meta_description}
|
||||||
|
{websiteUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={false} {apiUrl} />
|
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={false} {apiUrl} />
|
||||||
|
|||||||
@@ -7,8 +7,20 @@
|
|||||||
const {
|
const {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
isLegalPage
|
isLegalPage,
|
||||||
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
|
websiteUrl
|
||||||
|
}: {
|
||||||
|
websiteOverview: WebsiteOverview;
|
||||||
|
apiUrl: string;
|
||||||
|
isLegalPage: boolean;
|
||||||
|
websiteUrl: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sortedArticles = websiteOverview.article.sort((a, b) => {
|
||||||
|
if (!a.publication_date) return 1;
|
||||||
|
if (!b.publication_date) return -1;
|
||||||
|
return new Date(b.publication_date).getTime() - new Date(a.publication_date).getTime();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head
|
<Head
|
||||||
@@ -16,9 +28,11 @@
|
|||||||
nestingLevel={0}
|
nestingLevel={0}
|
||||||
{apiUrl}
|
{apiUrl}
|
||||||
title={isLegalPage ? "Legal information" : websiteOverview.title}
|
title={isLegalPage ? "Legal information" : websiteOverview.title}
|
||||||
|
metaDescription={websiteOverview.home.meta_description}
|
||||||
|
{websiteUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {apiUrl} />
|
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {isLegalPage} {apiUrl} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -41,7 +55,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="unpadded">
|
<ul class="unpadded">
|
||||||
{#each websiteOverview.article as article}
|
{#each sortedArticles as article}
|
||||||
<li>
|
<li>
|
||||||
{#if article.publication_date}
|
{#if article.publication_date}
|
||||||
<p>{article.publication_date}</p>
|
<p>{article.publication_date}</p>
|
||||||
|
|||||||
@@ -1,31 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { WebsiteOverview } from "../../utils";
|
import { slugify, type WebsiteOverview } from "../../utils";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
nestingLevel,
|
nestingLevel,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
title,
|
title,
|
||||||
metaDescription
|
metaDescription,
|
||||||
|
websiteUrl
|
||||||
}: {
|
}: {
|
||||||
websiteOverview: WebsiteOverview;
|
websiteOverview: WebsiteOverview;
|
||||||
nestingLevel: number;
|
nestingLevel: number;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
title: string;
|
title: string;
|
||||||
metaDescription?: string | null;
|
metaDescription?: string | null;
|
||||||
|
websiteUrl: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const constructedTitle =
|
||||||
|
websiteOverview.title === title ? title : `${websiteOverview.title} | ${title}`;
|
||||||
|
|
||||||
|
let ogUrl = `${websiteUrl.replace(/\/$/, "")}${nestingLevel === 0 ? (websiteOverview.title === title ? "" : `/${slugify(title)}`) : `/articles/${slugify(title)}`}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{title}</title>
|
<title>{constructedTitle}</title>
|
||||||
<meta name="description" content={metaDescription ?? title} />
|
<meta name="description" content={metaDescription ?? title} />
|
||||||
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}styles.css`} />
|
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}variables.css`} />
|
||||||
|
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}common.css`} />
|
||||||
|
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}scoped.css`} />
|
||||||
{#if websiteOverview.settings.favicon_image}
|
{#if websiteOverview.settings.favicon_image}
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}"
|
href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
<meta property="og:site_name" content={websiteOverview.title} />
|
||||||
|
<meta property="og:title" content={constructedTitle} />
|
||||||
|
<meta property="og:description" content={metaDescription ?? title} />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content={ogUrl} />
|
||||||
|
{#if websiteOverview.header.logo_image}
|
||||||
|
<meta
|
||||||
|
property="og:image"
|
||||||
|
content="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<script>
|
||||||
|
const determineTheme = (skipSetTheme = false) => {
|
||||||
|
const lightMode = window
|
||||||
|
.getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--display-light");
|
||||||
|
const darkMode = window
|
||||||
|
.getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--display-dark");
|
||||||
|
|
||||||
|
if (!skipSetTheme && lightMode === "none") {
|
||||||
|
localStorage.setItem("theme", "light");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipSetTheme && darkMode === "none") {
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTheme = localStorage.getItem("theme");
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
document.querySelector("#toggle-theme").checked =
|
||||||
|
darkMode === "none" ? currentTheme === "light" : currentTheme === "dark";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
determineTheme(true);
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
document.querySelector('label[for="toggle-theme"]').addEventListener("click", () => {
|
||||||
|
determineTheme();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
websiteOverview,
|
websiteOverview,
|
||||||
isDocsTemplate,
|
isDocsTemplate,
|
||||||
isIndexPage,
|
isIndexPage,
|
||||||
apiUrl
|
apiUrl,
|
||||||
|
isLegalPage
|
||||||
}: {
|
}: {
|
||||||
websiteOverview: WebsiteOverview;
|
websiteOverview: WebsiteOverview;
|
||||||
isDocsTemplate: boolean;
|
isDocsTemplate: boolean;
|
||||||
isIndexPage: boolean;
|
isIndexPage: boolean;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
|
isLegalPage?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const categorizedArticles = Object.fromEntries(
|
const categorizedArticles = Object.fromEntries(
|
||||||
@@ -70,7 +72,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<a href={isIndexPage ? "." : ".."}>
|
<svelte:element
|
||||||
|
this={isIndexPage && !isLegalPage ? "span" : "a"}
|
||||||
|
href={`${isLegalPage ? "./" : "../"}`}
|
||||||
|
>
|
||||||
{#if websiteOverview.header.logo_type === "text"}
|
{#if websiteOverview.header.logo_type === "text"}
|
||||||
<strong>{websiteOverview.header.logo_text}</strong>
|
<strong>{websiteOverview.header.logo_text}</strong>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -81,6 +86,33 @@
|
|||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</svelte:element>
|
||||||
|
<label style="margin-inline-start: auto;" for="toggle-theme">
|
||||||
|
<input type="checkbox" id="toggle-theme" hidden />
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 15ZM10 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM15.657 5.404a.75.75 0 1 0-1.06-1.06l-1.061 1.06a.75.75 0 0 0 1.06 1.06l1.06-1.06ZM6.464 14.596a.75.75 0 1 0-1.06-1.06l-1.06 1.06a.75.75 0 0 0 1.06 1.06l1.06-1.06ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM5 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 5 10ZM14.596 15.657a.75.75 0 0 0 1.06-1.06l-1.06-1.061a.75.75 0 1 0-1.06 1.06l1.06 1.06ZM5.404 6.464a.75.75 0 0 0 1.06-1.06l-1.06-1.06a.75.75 0 1 0-1.061 1.06l1.06 1.06Z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
const {
|
const {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
article,
|
article,
|
||||||
apiUrl
|
apiUrl,
|
||||||
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
|
websiteUrl
|
||||||
|
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string; websiteUrl: string } =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head
|
<Head
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
{apiUrl}
|
{apiUrl}
|
||||||
title={article.title}
|
title={article.title}
|
||||||
metaDescription={article.meta_description}
|
metaDescription={article.meta_description}
|
||||||
|
{websiteUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={false} {apiUrl} />
|
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={false} {apiUrl} />
|
||||||
|
|||||||
@@ -7,8 +7,14 @@
|
|||||||
const {
|
const {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
isLegalPage
|
isLegalPage,
|
||||||
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
|
websiteUrl
|
||||||
|
}: {
|
||||||
|
websiteOverview: WebsiteOverview;
|
||||||
|
apiUrl: string;
|
||||||
|
isLegalPage: boolean;
|
||||||
|
websiteUrl: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head
|
<Head
|
||||||
@@ -16,9 +22,11 @@
|
|||||||
nestingLevel={0}
|
nestingLevel={0}
|
||||||
{apiUrl}
|
{apiUrl}
|
||||||
title={isLegalPage ? "Legal information" : websiteOverview.title}
|
title={isLegalPage ? "Legal information" : websiteOverview.title}
|
||||||
|
metaDescription={websiteOverview.home.meta_description}
|
||||||
|
{websiteUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {apiUrl} />
|
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {isLegalPage} {apiUrl} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Renderer, Token } from "marked";
|
|||||||
import { markedHighlight } from "marked-highlight";
|
import { markedHighlight } from "marked-highlight";
|
||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
import { applyAction, deserialize } from "$app/forms";
|
|
||||||
import type {
|
import type {
|
||||||
Website,
|
Website,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -148,12 +147,18 @@ const createMarkdownParser = (showToc = true) => {
|
|||||||
|
|
||||||
export const md = (markdownContent: string, showToc = true) => {
|
export const md = (markdownContent: string, showToc = true) => {
|
||||||
const marked = createMarkdownParser(showToc);
|
const marked = createMarkdownParser(showToc);
|
||||||
const html = DOMPurify.sanitize(marked.parse(markdownContent) as string);
|
let html = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
|
||||||
|
} catch (error) {
|
||||||
|
html = JSON.stringify(error);
|
||||||
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOADING_DELAY = 500;
|
export const LOADING_DELAY = 250;
|
||||||
let loadingDelay: number;
|
let loadingDelay: number;
|
||||||
|
|
||||||
export const enhanceForm = (options?: {
|
export const enhanceForm = (options?: {
|
||||||
@@ -174,6 +179,8 @@ export const enhanceForm = (options?: {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PAGINATION_MAX_ITEMS = 20;
|
||||||
|
|
||||||
export const hexToHSL = (hex: string) => {
|
export const hexToHSL = (hex: string) => {
|
||||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const actions: Actions = {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
cookies.set("session_token", response.data.token, { path: "/" });
|
cookies.set("session_token", response.data.token, { path: "/", maxAge: 86400 });
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,5 +25,5 @@
|
|||||||
<input type="password" name="password" required />
|
<input type="password" name="password" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -31,27 +31,68 @@
|
|||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
Account registration is disabled on this instance
|
Registration is disabled
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<form method="POST" use:enhance={enhanceForm()}>
|
<div class="registration-wrapper">
|
||||||
<label>
|
<form method="POST" use:enhance={enhanceForm()}>
|
||||||
Username:
|
<label>
|
||||||
<input type="text" name="username" minlength="3" maxlength="16" required />
|
Username:
|
||||||
</label>
|
<input
|
||||||
<label>
|
type="text"
|
||||||
Password:
|
name="username"
|
||||||
<input type="password" name="password" minlength="12" maxlength="128" required />
|
minlength="3"
|
||||||
</label>
|
maxlength="16"
|
||||||
|
pattern="^[a-zA-Z0-9_\-]+$"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password:
|
||||||
|
<input type="password" name="password" minlength="12" maxlength="128" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Register</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Password requirements</summary>
|
||||||
|
<ul>
|
||||||
|
<li>Must be between 12 and 128 characters long</li>
|
||||||
|
<li>Must contain at least one lowercase letter</li>
|
||||||
|
<li>Must contain at least one uppercase letter</li>
|
||||||
|
<li>Must contain at least one number</li>
|
||||||
|
<li>Must contain at least one special character</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.registration-disabled {
|
.registration-disabled {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2xs);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.registration-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-wrapper > form {
|
||||||
|
inline-size: 30ch;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-wrapper > details {
|
||||||
|
inline-size: 35ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.registration-wrapper > form {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { apiRequest } from "$lib/server/utils";
|
import { apiRequest } from "$lib/server/utils";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
import { rm } from "node:fs/promises";
|
import type { Collab, Website } from "$lib/db-schema";
|
||||||
import { join } from "node:path";
|
|
||||||
import type { Website } from "$lib/db-schema";
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||||
const searchQuery = url.searchParams.get("website_search_query");
|
const searchQuery = url.searchParams.get("query");
|
||||||
const filterBy = url.searchParams.get("website_filter");
|
const filterBy = url.searchParams.get("filter");
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
const baseFetchUrl = `${API_BASE_PREFIX}/website?order=last_modified_at.desc,created_at.desc`;
|
const baseFetchUrl = `${API_BASE_PREFIX}/website?select=*,collab(user_id,permission_level)&collab.user_id=eq.${locals.user.id}&or=(user_id.eq.${locals.user.id},collab.not.is.null)&order=last_modified_at.desc,created_at.desc`;
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
params.append("title_search", `wfts(english).${searchQuery}`);
|
params.append("title", `wfts.${searchQuery}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (filterBy) {
|
switch (filterBy) {
|
||||||
@@ -39,7 +37,7 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
|||||||
totalWebsites.data.headers.get("content-range")?.split("/").at(-1)
|
totalWebsites.data.headers.get("content-range")?.split("/").at(-1)
|
||||||
);
|
);
|
||||||
|
|
||||||
const websites: Website[] = (
|
const websites: (Website & { collab: Collab[] })[] = (
|
||||||
await apiRequest(fetch, constructedFetchUrl, "GET", {
|
await apiRequest(fetch, constructedFetchUrl, "GET", {
|
||||||
returnData: true
|
returnData: true
|
||||||
})
|
})
|
||||||
@@ -47,7 +45,8 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
totalWebsiteCount,
|
totalWebsiteCount,
|
||||||
websites
|
websites,
|
||||||
|
user: locals.user
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,15 +76,6 @@ export const actions: Actions = {
|
|||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const id = data.get("id");
|
const id = data.get("id");
|
||||||
|
|
||||||
const oldDomainPrefix = (
|
|
||||||
await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${id}`, "GET", {
|
|
||||||
headers: {
|
|
||||||
Accept: "application/vnd.pgrst.object+json"
|
|
||||||
},
|
|
||||||
returnData: true
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
|
|
||||||
const deleteWebsite = await apiRequest(
|
const deleteWebsite = await apiRequest(
|
||||||
fetch,
|
fetch,
|
||||||
`${API_BASE_PREFIX}/website?id=eq.${id}`,
|
`${API_BASE_PREFIX}/website?id=eq.${id}`,
|
||||||
@@ -99,16 +89,6 @@ export const actions: Actions = {
|
|||||||
return deleteWebsite;
|
return deleteWebsite;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(join("/", "var", "www", "archtika-websites", "previews", id as string), {
|
|
||||||
recursive: true,
|
|
||||||
force: true
|
|
||||||
});
|
|
||||||
|
|
||||||
await rm(join("/", "var", "www", "archtika-websites", oldDomainPrefix?.prefix ?? id), {
|
|
||||||
recursive: true,
|
|
||||||
force: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return deleteWebsite;
|
return deleteWebsite;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<input type="text" name="title" maxlength="50" pattern="\S(.*\S)?" required />
|
<input type="text" name="title" maxlength="50" pattern="\S(.*\S)?" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Create website</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
@@ -55,36 +55,29 @@
|
|||||||
<form method="GET">
|
<form method="GET">
|
||||||
<label>
|
<label>
|
||||||
Search:
|
Search:
|
||||||
<input
|
<input type="text" name="query" value={$page.url.searchParams.get("query")} />
|
||||||
type="text"
|
|
||||||
name="website_search_query"
|
|
||||||
value={$page.url.searchParams.get("website_search_query")}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Filter:
|
Filter:
|
||||||
<select name="website_filter">
|
<select name="filter">
|
||||||
<option value="all" selected={"all" === $page.url.searchParams.get("website_filter")}
|
<option value="all" selected={"all" === $page.url.searchParams.get("filter")}
|
||||||
>Show all</option
|
>Show all</option
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
value="creations"
|
value="creations"
|
||||||
selected={"creations" === $page.url.searchParams.get("website_filter")}
|
selected={"creations" === $page.url.searchParams.get("filter")}>Created by you</option
|
||||||
>Created by you</option
|
|
||||||
>
|
>
|
||||||
<option
|
<option value="shared" selected={"shared" === $page.url.searchParams.get("filter")}
|
||||||
value="shared"
|
|
||||||
selected={"shared" === $page.url.searchParams.get("website_filter")}
|
|
||||||
>Shared with you</option
|
>Shared with you</option
|
||||||
>
|
>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Apply</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<ul class="website-grid unpadded">
|
<ul class="website-grid unpadded">
|
||||||
{#each data.websites as { id, content_type, title, created_at } (id)}
|
{#each data.websites as { id, user_id, content_type, title, created_at, collab } (id)}
|
||||||
<li class="website-card">
|
<li class="website-card">
|
||||||
<p>
|
<p>
|
||||||
<strong>
|
<strong>
|
||||||
@@ -112,7 +105,7 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={id} />
|
<input type="hidden" name="id" value={id} />
|
||||||
<label>
|
<label>
|
||||||
Title
|
Title:
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
@@ -123,7 +116,11 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={data.user.id !== user_id && collab[0].permission_level !== 30}
|
||||||
|
>Update website</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal id="delete-website-{id}" text="Delete">
|
<Modal id="delete-website-{id}" text="Delete">
|
||||||
@@ -140,7 +137,7 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={id} />
|
<input type="hidden" name="id" value={id} />
|
||||||
|
|
||||||
<button type="submit">Delete website</button>
|
<button type="submit" disabled={data.user.id !== user_id}>Delete website</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
|
const storageSizes = await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/rpc/user_websites_storage_size`,
|
||||||
|
"GET",
|
||||||
|
{
|
||||||
|
returnData: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user
|
user: locals.user,
|
||||||
|
storageSizes
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,7 +21,7 @@ export const actions: Actions = {
|
|||||||
logout: async ({ cookies }) => {
|
logout: async ({ cookies }) => {
|
||||||
cookies.delete("session_token", { path: "/" });
|
cookies.delete("session_token", { path: "/" });
|
||||||
|
|
||||||
return { success: true, message: "Successfully logged out" };
|
return { success: true, message: "Successfully logged out, you can refresh the page" };
|
||||||
},
|
},
|
||||||
deleteAccount: async ({ request, fetch, cookies }) => {
|
deleteAccount: async ({ request, fetch, cookies }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
|||||||
@@ -33,6 +33,30 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if (data.storageSizes.data ?? []).length > 0}
|
||||||
|
<section id="storage">
|
||||||
|
<h2>
|
||||||
|
<a href="#storage">Storage</a>
|
||||||
|
</h2>
|
||||||
|
<ul class="unpadded storage-grid">
|
||||||
|
{#each data.storageSizes.data as { website_title, storage_size_bytes, max_storage_bytes, max_storage_pretty, diff_storage_pretty }}
|
||||||
|
<li>
|
||||||
|
<strong>{website_title}</strong>
|
||||||
|
<label>
|
||||||
|
{max_storage_pretty} total — {diff_storage_pretty} free<br />
|
||||||
|
<meter
|
||||||
|
value={storage_size_bytes}
|
||||||
|
min="0"
|
||||||
|
max={max_storage_bytes}
|
||||||
|
high={max_storage_bytes * 0.75}
|
||||||
|
></meter>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section id="logout">
|
<section id="logout">
|
||||||
<h2>
|
<h2>
|
||||||
<a href="#logout">Logout</a>
|
<a href="#logout">Logout</a>
|
||||||
@@ -71,4 +95,17 @@
|
|||||||
form[action="?/logout"] > button {
|
form[action="?/logout"] > button {
|
||||||
max-inline-size: fit-content;
|
max-inline-size: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.storage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 35ch), 1fr));
|
||||||
|
row-gap: var(--space-s);
|
||||||
|
column-gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-grid > li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3xs);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
80
web-app/src/routes/(authenticated)/manage/+page.server.ts
Normal file
80
web-app/src/routes/(authenticated)/manage/+page.server.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
|
import type { Website, User } from "$lib/db-schema";
|
||||||
|
import { PAGINATION_MAX_ITEMS } from "$lib/utils";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
|
const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1");
|
||||||
|
const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS;
|
||||||
|
|
||||||
|
const usersWithWebsites: (User & { website: Website[] })[] = (
|
||||||
|
await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/user?select=*,website!user_id(*)&order=created_at&limit=${PAGINATION_MAX_ITEMS}&offset=${resultOffset}`,
|
||||||
|
"GET",
|
||||||
|
{
|
||||||
|
returnData: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
|
||||||
|
const resultUsers = await apiRequest(fetch, `${API_BASE_PREFIX}/user`, "HEAD", {
|
||||||
|
headers: {
|
||||||
|
Prefer: "count=exact"
|
||||||
|
},
|
||||||
|
returnData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultUsersCount = Number(resultUsers.data.headers.get("content-range")?.split("/").at(-1));
|
||||||
|
|
||||||
|
return {
|
||||||
|
usersWithWebsites,
|
||||||
|
API_BASE_PREFIX,
|
||||||
|
resultUsersCount
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
updateMaxWebsiteAmount: async ({ request, fetch }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
return await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
|
||||||
|
"PATCH",
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
max_number_websites: data.get("number-of-websites")
|
||||||
|
},
|
||||||
|
successMessage: "Successfully updated user website limit"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateStorageLimit: async ({ request, fetch }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
return await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`,
|
||||||
|
"PATCH",
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
max_storage_size: data.get("storage-size")
|
||||||
|
},
|
||||||
|
successMessage: "Successfully updated user website storage size"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deleteUser: async ({ request, fetch }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
return await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
|
||||||
|
"DELETE",
|
||||||
|
{
|
||||||
|
successMessage: "Successfully deleted user"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
134
web-app/src/routes/(authenticated)/manage/+page.svelte
Normal file
134
web-app/src/routes/(authenticated)/manage/+page.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
|
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
|
||||||
|
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
|
||||||
|
import type { ActionData, PageServerData } from "./$types";
|
||||||
|
import { enhanceForm } from "$lib/utils";
|
||||||
|
import { sending } from "$lib/runes.svelte";
|
||||||
|
import DateTime from "$lib/components/DateTime.svelte";
|
||||||
|
import Pagination from "$lib/components/Pagination.svelte";
|
||||||
|
|
||||||
|
const { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SuccessOrError success={form?.success} message={form?.message} />
|
||||||
|
|
||||||
|
{#if sending.value}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section id="all-users">
|
||||||
|
<hgroup>
|
||||||
|
<h2>
|
||||||
|
<a href="#all-users">All users</a>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
<strong>{data.resultUsersCount.toLocaleString("en", { useGrouping: true })}</strong>
|
||||||
|
<small>result(s)</small>
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
<div class="scroll-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account creation</th>
|
||||||
|
<th>UUID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Manage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.usersWithWebsites as { id, created_at, username, max_number_websites, website }}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<DateTime date={created_at} />
|
||||||
|
</td>
|
||||||
|
<td>{id}</td>
|
||||||
|
<td>{username}</td>
|
||||||
|
<td>
|
||||||
|
<Modal id="manage-user-{id}" text="Manage">
|
||||||
|
<hgroup>
|
||||||
|
<h3>Manage user</h3>
|
||||||
|
<p>User "{username}"</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateMaxWebsiteAmount"
|
||||||
|
use:enhance={enhanceForm({ reset: false })}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="user-id" value={id} />
|
||||||
|
<label>
|
||||||
|
Number of websites allowed:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="number-of-websites"
|
||||||
|
min="0"
|
||||||
|
value={max_number_websites}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Update website limit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if website.length > 0}
|
||||||
|
<h4>Websites</h4>
|
||||||
|
{#each website as { id, title, max_storage_size }}
|
||||||
|
<details>
|
||||||
|
<summary>{title}</summary>
|
||||||
|
<div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateStorageLimit"
|
||||||
|
use:enhance={enhanceForm({ reset: false })}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="website-id" value={id} />
|
||||||
|
<label>
|
||||||
|
Storage limit in MB:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="storage-size"
|
||||||
|
min="0"
|
||||||
|
value={max_storage_size}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Update storage limit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4>Delete user</h4>
|
||||||
|
<details>
|
||||||
|
<summary>Delete</summary>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Caution!</strong>
|
||||||
|
Deleting the user will irretrievably erase all their data.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteUser"
|
||||||
|
use:enhance={enhanceForm({ closeModal: true })}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="user-id" value={id} />
|
||||||
|
<button type="submit">Delete user</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</Modal>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination resultCount={data.resultUsersCount} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form[action="?/deleteUser"] {
|
||||||
|
margin-block-start: var(--space-2xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@ import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import type { Website, Home, User } from "$lib/db-schema";
|
import type { Website, Home, User } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ params, fetch }) => {
|
export const load: LayoutServerLoad = async ({ locals, params, fetch }) => {
|
||||||
const websiteData = await apiRequest(
|
const websiteData = await apiRequest(
|
||||||
fetch,
|
fetch,
|
||||||
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
|
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
|
||||||
@@ -31,8 +31,27 @@ export const load: LayoutServerLoad = async ({ params, fetch }) => {
|
|||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
|
let permissionLevel = 40;
|
||||||
|
|
||||||
|
if (website.user_id !== locals.user.id) {
|
||||||
|
permissionLevel = (
|
||||||
|
await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/collab?select=permission_level&website_id=eq.${params.websiteId}&user_id=eq.${locals.user.id}`,
|
||||||
|
"GET",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.pgrst.object+json"
|
||||||
|
},
|
||||||
|
returnData: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data.permission_level;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
website,
|
website,
|
||||||
home
|
home,
|
||||||
|
permissionLevel
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,37 +4,24 @@ import { apiRequest } from "$lib/server/utils";
|
|||||||
import type { Settings, Header, Footer } from "$lib/db-schema";
|
import type { Settings, Header, Footer } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||||
const globalSettings: Settings = (
|
const [globalSettingsResponse, headerResponse, footerResponse] = await Promise.all([
|
||||||
await apiRequest(
|
apiRequest(fetch, `${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, "GET", {
|
||||||
fetch,
|
headers: { Accept: "application/vnd.pgrst.object+json" },
|
||||||
`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`,
|
returnData: true
|
||||||
"GET",
|
}),
|
||||||
{
|
apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", {
|
||||||
headers: {
|
headers: { Accept: "application/vnd.pgrst.object+json" },
|
||||||
Accept: "application/vnd.pgrst.object+json"
|
returnData: true
|
||||||
},
|
}),
|
||||||
returnData: true
|
apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", {
|
||||||
}
|
headers: { Accept: "application/vnd.pgrst.object+json" },
|
||||||
)
|
|
||||||
).data;
|
|
||||||
|
|
||||||
const header: Header = (
|
|
||||||
await apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", {
|
|
||||||
headers: {
|
|
||||||
Accept: "application/vnd.pgrst.object+json"
|
|
||||||
},
|
|
||||||
returnData: true
|
returnData: true
|
||||||
})
|
})
|
||||||
).data;
|
]);
|
||||||
|
|
||||||
const footer: Footer = (
|
const globalSettings: Settings = globalSettingsResponse.data;
|
||||||
await apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", {
|
const header: Header = headerResponse.data;
|
||||||
headers: {
|
const footer: Footer = footerResponse.data;
|
||||||
Accept: "application/vnd.pgrst.object+json"
|
|
||||||
},
|
|
||||||
returnData: true
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
globalSettings,
|
globalSettings,
|
||||||
@@ -56,7 +43,6 @@ export const actions: Actions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (faviconFile) {
|
if (faviconFile) {
|
||||||
headers["X-Mimetype"] = faviconFile.type;
|
|
||||||
headers["X-Original-Filename"] = faviconFile.name;
|
headers["X-Original-Filename"] = faviconFile.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +83,6 @@ export const actions: Actions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (logoImage) {
|
if (logoImage) {
|
||||||
headers["X-Mimetype"] = logoImage.type;
|
|
||||||
headers["X-Original-Filename"] = logoImage.name;
|
headers["X-Original-Filename"] = logoImage.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +119,8 @@ export const actions: Actions = {
|
|||||||
"PATCH",
|
"PATCH",
|
||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
main_content: data.get("main-content")
|
main_content: data.get("main-content"),
|
||||||
|
meta_description: data.get("description")
|
||||||
},
|
},
|
||||||
successMessage: "Successfully updated home"
|
successMessage: "Successfully updated home"
|
||||||
}
|
}
|
||||||
@@ -164,7 +150,6 @@ export const actions: Actions = {
|
|||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
Accept: "application/vnd.pgrst.object+json",
|
Accept: "application/vnd.pgrst.object+json",
|
||||||
"X-Website-Id": params.websiteId,
|
"X-Website-Id": params.websiteId,
|
||||||
"X-Mimetype": file.type,
|
|
||||||
"X-Original-Filename": file.name
|
"X-Original-Filename": file.name
|
||||||
},
|
},
|
||||||
body: await file.arrayBuffer(),
|
body: await file.arrayBuffer(),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
import { sending } from "$lib/runes.svelte";
|
import { sending } from "$lib/runes.svelte";
|
||||||
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
||||||
import { previewContent } from "$lib/runes.svelte";
|
import { previewContent } from "$lib/runes.svelte";
|
||||||
|
|
||||||
const { data, form }: { data: PageServerData & LayoutServerData; form: ActionData } = $props();
|
const { data, form }: { data: PageServerData & LayoutServerData; form: ActionData } = $props();
|
||||||
|
|
||||||
previewContent.value = data.home.main_content;
|
previewContent.value = data.home.main_content;
|
||||||
@@ -83,7 +82,11 @@
|
|||||||
<input type="file" name="favicon" accept={ALLOWED_MIME_TYPES.join(", ")} />
|
<input type="file" name="favicon" accept={ALLOWED_MIME_TYPES.join(", ")} />
|
||||||
</label>
|
</label>
|
||||||
{#if data.globalSettings.favicon_image}
|
{#if data.globalSettings.favicon_image}
|
||||||
<Modal id="preview-favicon-global-{data.globalSettings.website_id}" text="Preview">
|
<Modal
|
||||||
|
id="preview-favicon-global-{data.globalSettings.website_id}"
|
||||||
|
text="Preview"
|
||||||
|
isWider={true}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.globalSettings.favicon_image}`}
|
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.globalSettings.favicon_image}`}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -92,7 +95,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Update global</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -130,7 +133,7 @@
|
|||||||
<input type="file" name="logo-image" accept={ALLOWED_MIME_TYPES.join(", ")} />
|
<input type="file" name="logo-image" accept={ALLOWED_MIME_TYPES.join(", ")} />
|
||||||
</label>
|
</label>
|
||||||
{#if data.header.logo_image}
|
{#if data.header.logo_image}
|
||||||
<Modal id="preview-logo-header-{data.header.website_id}" text="Preview">
|
<Modal id="preview-logo-header-{data.header.website_id}" text="Preview" isWider={true}>
|
||||||
<img
|
<img
|
||||||
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.header.logo_image}`}
|
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.header.logo_image}`}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -139,7 +142,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Update header</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -149,6 +152,12 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form action="?/updateHome" method="POST" use:enhance={enhanceForm({ reset: false })}>
|
<form action="?/updateHome" method="POST" use:enhance={enhanceForm({ reset: false })}>
|
||||||
|
<label>
|
||||||
|
Description:
|
||||||
|
<textarea name="description" rows="5" maxlength="250" required
|
||||||
|
>{data.home.meta_description}</textarea
|
||||||
|
>
|
||||||
|
</label>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
apiPrefix={data.API_BASE_PREFIX}
|
apiPrefix={data.API_BASE_PREFIX}
|
||||||
label="Main content"
|
label="Main content"
|
||||||
@@ -156,7 +165,7 @@
|
|||||||
content={data.home.main_content}
|
content={data.home.main_content}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Update home</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -173,7 +182,7 @@
|
|||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Update footer</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</WebsiteEditor>
|
</WebsiteEditor>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { apiRequest } from "$lib/server/utils";
|
|||||||
import type { Article, DocsCategory } from "$lib/db-schema";
|
import type { Article, DocsCategory } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, url, parent, locals }) => {
|
export const load: PageServerLoad = async ({ params, fetch, url, parent, locals }) => {
|
||||||
const searchQuery = url.searchParams.get("article_search_query");
|
const searchQuery = url.searchParams.get("query");
|
||||||
const filterBy = url.searchParams.get("article_filter");
|
const filterBy = url.searchParams.get("filter");
|
||||||
|
|
||||||
const { website, home } = await parent();
|
const { website, home, permissionLevel } = await parent();
|
||||||
|
|
||||||
let baseFetchUrl = `${API_BASE_PREFIX}/article?website_id=eq.${params.websiteId}&select=id,title`;
|
let baseFetchUrl = `${API_BASE_PREFIX}/article?website_id=eq.${params.websiteId}&select=id,user_id,title`;
|
||||||
if (website.content_type === "Docs") {
|
if (website.content_type === "Docs") {
|
||||||
baseFetchUrl +=
|
baseFetchUrl +=
|
||||||
",article_weight,docs_category(category_name,category_weight)&order=docs_category(category_weight).desc.nullslast,article_weight.desc.nullslast";
|
",article_weight,docs_category(category_name,category_weight)&order=docs_category(category_weight).desc.nullslast,article_weight.desc.nullslast";
|
||||||
@@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ params, fetch, url, parent, locals
|
|||||||
const parameters = new URLSearchParams();
|
const parameters = new URLSearchParams();
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
parameters.append("title_description_search", `wfts(english).${searchQuery}`);
|
parameters.append("title", `wfts.${searchQuery}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (filterBy) {
|
switch (filterBy) {
|
||||||
@@ -56,7 +56,9 @@ export const load: PageServerLoad = async ({ params, fetch, url, parent, locals
|
|||||||
totalArticleCount,
|
totalArticleCount,
|
||||||
articles,
|
articles,
|
||||||
website,
|
website,
|
||||||
home
|
home,
|
||||||
|
permissionLevel,
|
||||||
|
user: locals.user
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
|
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Create article</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
@@ -56,36 +56,30 @@
|
|||||||
<form method="GET">
|
<form method="GET">
|
||||||
<label>
|
<label>
|
||||||
Search:
|
Search:
|
||||||
<input
|
<input type="text" name="query" value={$page.url.searchParams.get("query")} />
|
||||||
type="text"
|
|
||||||
name="article_search_query"
|
|
||||||
value={$page.url.searchParams.get("article_search_query")}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Filter:
|
Filter:
|
||||||
<select name="article_filter">
|
<select name="filter">
|
||||||
<option value="all" selected={"all" === $page.url.searchParams.get("article_filter")}
|
<option value="all" selected={"all" === $page.url.searchParams.get("filter")}
|
||||||
>Show all</option
|
>Show all</option
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
value="creations"
|
value="creations"
|
||||||
selected={"creations" === $page.url.searchParams.get("article_filter")}
|
selected={"creations" === $page.url.searchParams.get("filter")}
|
||||||
>Created by you</option
|
>Created by you</option
|
||||||
>
|
>
|
||||||
<option
|
<option value="shared" selected={"shared" === $page.url.searchParams.get("filter")}
|
||||||
value="shared"
|
|
||||||
selected={"shared" === $page.url.searchParams.get("article_filter")}
|
|
||||||
>Created by others</option
|
>Created by others</option
|
||||||
>
|
>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Apply</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<ul class="unpadded">
|
<ul class="unpadded">
|
||||||
{#each data.articles as { id, title, article_weight, docs_category } (id)}
|
{#each data.articles as { id, user_id, title, article_weight, docs_category } (id)}
|
||||||
<li class="article-card">
|
<li class="article-card">
|
||||||
<p>
|
<p>
|
||||||
<strong>{title} {article_weight ? `(${article_weight})` : ""}</strong>
|
<strong>{title} {article_weight ? `(${article_weight})` : ""}</strong>
|
||||||
@@ -129,7 +123,12 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={id} />
|
<input type="hidden" name="id" value={id} />
|
||||||
|
|
||||||
<button type="submit">Delete article</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={data.permissionLevel === 10 ||
|
||||||
|
(data.permissionLevel === 20 && user_id !== data.user.id)}
|
||||||
|
>Delete article</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export const load: PageServerLoad = async ({ parent, params, fetch }) => {
|
|||||||
)
|
)
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
const { website } = await parent();
|
const { website, permissionLevel } = await parent();
|
||||||
|
|
||||||
return { website, article, categories, API_BASE_PREFIX };
|
return { website, article, categories, API_BASE_PREFIX, permissionLevel };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -40,7 +40,6 @@ export const actions: Actions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (coverFile) {
|
if (coverFile) {
|
||||||
headers["X-Mimetype"] = coverFile.type;
|
|
||||||
headers["X-Original-Filename"] = coverFile.name;
|
headers["X-Original-Filename"] = coverFile.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ export const actions: Actions = {
|
|||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
Accept: "application/vnd.pgrst.object+json",
|
Accept: "application/vnd.pgrst.object+json",
|
||||||
"X-Website-Id": params.websiteId,
|
"X-Website-Id": params.websiteId,
|
||||||
"X-Mimetype": file.type,
|
|
||||||
"X-Original-Filename": file.name
|
"X-Original-Filename": file.name
|
||||||
},
|
},
|
||||||
body: await file.arrayBuffer(),
|
body: await file.arrayBuffer(),
|
||||||
|
|||||||
@@ -44,14 +44,16 @@
|
|||||||
<input type="number" name="article-weight" value={data.article.article_weight} min="0" />
|
<input type="number" name="article-weight" value={data.article.article_weight} min="0" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
{#if data.categories.length > 0}
|
||||||
Category:
|
<label>
|
||||||
<select name="category">
|
Category:
|
||||||
{#each data.categories as { id, category_name }}
|
<select name="category">
|
||||||
<option value={id} selected={id === data.article.category}>{category_name}</option>
|
{#each data.categories as { id, category_name }}
|
||||||
{/each}
|
<option value={id} selected={id === data.article.category}>{category_name}</option>
|
||||||
</select>
|
{/each}
|
||||||
</label>
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
<input type="file" name="cover-image" accept={ALLOWED_MIME_TYPES.join(", ")} />
|
<input type="file" name="cover-image" accept={ALLOWED_MIME_TYPES.join(", ")} />
|
||||||
</label>
|
</label>
|
||||||
{#if data.article.cover_image}
|
{#if data.article.cover_image}
|
||||||
<Modal id="preview-cover-article-{data.article.id}" text="Preview">
|
<Modal id="preview-cover-article-{data.article.id}" text="Preview" isWider={true}>
|
||||||
<img
|
<img
|
||||||
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.article.cover_image}`}
|
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.article.cover_image}`}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -117,7 +119,7 @@
|
|||||||
content={data.article.main_content ?? ""}
|
content={data.article.main_content ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Update article</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</WebsiteEditor>
|
</WebsiteEditor>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
|
|||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
import type { DocsCategory } from "$lib/db-schema";
|
import type { DocsCategory } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, params, fetch }) => {
|
export const load: PageServerLoad = async ({ parent, params, fetch, locals }) => {
|
||||||
const categories: DocsCategory[] = (
|
const categories: DocsCategory[] = (
|
||||||
await apiRequest(
|
await apiRequest(
|
||||||
fetch,
|
fetch,
|
||||||
@@ -14,12 +14,14 @@ export const load: PageServerLoad = async ({ parent, params, fetch }) => {
|
|||||||
)
|
)
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
const { website, home } = await parent();
|
const { website, home, permissionLevel } = await parent();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
website,
|
website,
|
||||||
home
|
home,
|
||||||
|
permissionLevel,
|
||||||
|
user: locals.user
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<input name="category-weight" type="number" min="0" required />
|
<input name="category-weight" type="number" min="0" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={data.permissionLevel === 10}>Create category</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="unpadded">
|
<ul class="unpadded">
|
||||||
{#each data.categories as { id, website_id, category_name, category_weight } (`${website_id}-${id}`)}
|
{#each data.categories as { id, website_id, user_id, category_name, category_weight } (`${website_id}-${id}`)}
|
||||||
<li class="category-card">
|
<li class="category-card">
|
||||||
<p>
|
<p>
|
||||||
<strong>{category_name} ({category_weight})</strong>
|
<strong>{category_name} ({category_weight})</strong>
|
||||||
@@ -89,7 +89,9 @@
|
|||||||
<input type="number" name="category-weight" value={category_weight} min="0" />
|
<input type="number" name="category-weight" value={category_weight} min="0" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Update category</button>
|
<button type="submit" disabled={data.permissionLevel === 10}
|
||||||
|
>Update category</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal id="delete-category-{id}" text="Delete">
|
<Modal id="delete-category-{id}" text="Delete">
|
||||||
@@ -104,7 +106,12 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="category-id" value={id} />
|
<input type="hidden" name="category-id" value={id} />
|
||||||
|
|
||||||
<button type="submit">Delete category</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={data.permissionLevel === 10 ||
|
||||||
|
(data.permissionLevel === 20 && user_id !== data.user.id)}
|
||||||
|
>Delete category</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
|
|||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
import type { Collab, User } from "$lib/db-schema";
|
import type { Collab, User } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, params, fetch }) => {
|
export const load: PageServerLoad = async ({ parent, params, fetch, locals }) => {
|
||||||
const collaborators: (Collab & { user: User })[] = (
|
const collaborators: (Collab & { user: User })[] = (
|
||||||
await apiRequest(
|
await apiRequest(
|
||||||
fetch,
|
fetch,
|
||||||
@@ -14,12 +14,14 @@ export const load: PageServerLoad = async ({ parent, params, fetch }) => {
|
|||||||
)
|
)
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
const { website, home } = await parent();
|
const { website, home, permissionLevel } = await parent();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
website,
|
website,
|
||||||
home,
|
home,
|
||||||
collaborators
|
collaborators,
|
||||||
|
permissionLevel,
|
||||||
|
user: locals.user
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
|
||||||
|
>Add collaborator</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
@@ -89,7 +91,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Update collaborator</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={[10, 20].includes(data.permissionLevel) || user_id === data.user.id}
|
||||||
|
>Update collaborator</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal id="remove-collaborator-{user_id}" text="Remove">
|
<Modal id="remove-collaborator-{user_id}" text="Remove">
|
||||||
@@ -104,7 +110,11 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="user-id" value={user_id} />
|
<input type="hidden" name="user-id" value={user_id} />
|
||||||
|
|
||||||
<button type="submit">Remove collaborator</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={[10, 20].includes(data.permissionLevel) || user_id === data.user.id}
|
||||||
|
>Remove collaborator</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
import { rm } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type { LegalInformation } from "$lib/db-schema";
|
import type { LegalInformation } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, fetch, params }) => {
|
export const load: PageServerLoad = async ({ parent, fetch, params }) => {
|
||||||
@@ -19,12 +17,13 @@ export const load: PageServerLoad = async ({ parent, fetch, params }) => {
|
|||||||
)
|
)
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
const { website } = await parent();
|
const { website, permissionLevel } = await parent();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
legalInformation,
|
legalInformation,
|
||||||
website,
|
website,
|
||||||
API_BASE_PREFIX
|
API_BASE_PREFIX,
|
||||||
|
permissionLevel
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,11 +57,22 @@ export const actions: Actions = {
|
|||||||
return deleteLegalInformation;
|
return deleteLegalInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(
|
|
||||||
join("/", "var", "www", "archtika-websites", params.websiteId, "legal-information.html"),
|
|
||||||
{ force: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
return deleteLegalInformation;
|
return deleteLegalInformation;
|
||||||
|
},
|
||||||
|
pasteImage: async ({ request, fetch, params }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const file = data.get("file") as File;
|
||||||
|
|
||||||
|
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
Accept: "application/vnd.pgrst.object+json",
|
||||||
|
"X-Website-Id": params.websiteId,
|
||||||
|
"X-Original-Filename": file.name
|
||||||
|
},
|
||||||
|
body: await file.arrayBuffer(),
|
||||||
|
successMessage: "Successfully uploaded image",
|
||||||
|
returnData: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
content={data.legalInformation?.main_content ?? ""}
|
content={data.legalInformation?.main_content ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
|
||||||
|
>Update legal information</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if data.legalInformation?.main_content}
|
{#if data.legalInformation?.main_content}
|
||||||
@@ -76,7 +78,9 @@
|
|||||||
<strong>Caution!</strong>
|
<strong>Caution!</strong>
|
||||||
This action will remove the legal information page from the website and delete all data.
|
This action will remove the legal information page from the website and delete all data.
|
||||||
</p>
|
</p>
|
||||||
<button type="submit">Delete legal information</button>
|
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
|
||||||
|
>Delete legal information</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad, Actions } from "./$types";
|
||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
import type { ChangeLog, User, Collab } from "$lib/db-schema";
|
import type { ChangeLog, User, Collab } from "$lib/db-schema";
|
||||||
|
import DiffMatchPatch from "diff-match-patch";
|
||||||
|
import { PAGINATION_MAX_ITEMS } from "$lib/utils";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
|
export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
|
||||||
const userFilter = url.searchParams.get("logs_filter_user");
|
const userFilter = url.searchParams.get("user");
|
||||||
const resourceFilter = url.searchParams.get("logs_filter_resource");
|
const resourceFilter = url.searchParams.get("resource");
|
||||||
const operationFilter = url.searchParams.get("logs_filter_operation");
|
const operationFilter = url.searchParams.get("operation");
|
||||||
const currentPage = Number.parseInt(url.searchParams.get("logs_results_page") ?? "1");
|
const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1");
|
||||||
const resultOffset = (currentPage - 1) * 50;
|
const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS;
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -25,10 +27,13 @@ export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
|
|||||||
searchParams.append("operation", `eq.${operationFilter.toUpperCase()}`);
|
searchParams.append("operation", `eq.${operationFilter.toUpperCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}&limit=50&offset=${resultOffset}`;
|
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}&limit=${PAGINATION_MAX_ITEMS}&offset=${resultOffset}`;
|
||||||
|
|
||||||
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] = (
|
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] = (
|
||||||
await apiRequest(fetch, constructedFetchUrl, "GET", { returnData: true })
|
await apiRequest(fetch, constructedFetchUrl, "GET", {
|
||||||
|
headers: { Accept: "application/vnd.pgrst.array+json;nulls=stripped" },
|
||||||
|
returnData: true
|
||||||
|
})
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
const resultChangeLogData = await apiRequest(fetch, constructedFetchUrl, "HEAD", {
|
const resultChangeLogData = await apiRequest(fetch, constructedFetchUrl, "HEAD", {
|
||||||
@@ -61,3 +66,49 @@ export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
|
|||||||
collaborators
|
collaborators
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
computeDiff: async ({ request, fetch }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
const dmp = new DiffMatchPatch();
|
||||||
|
|
||||||
|
const htmlDiff = (oldValue: string, newValue: string) => {
|
||||||
|
const diff = dmp.diff_main(oldValue, newValue);
|
||||||
|
dmp.diff_cleanupSemantic(diff);
|
||||||
|
|
||||||
|
return diff
|
||||||
|
.map(([op, text]) => {
|
||||||
|
switch (op) {
|
||||||
|
case 1:
|
||||||
|
return `<ins>${text}</ins>`;
|
||||||
|
case -1:
|
||||||
|
return `<del>${text}</del>`;
|
||||||
|
default:
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const log: ChangeLog = (
|
||||||
|
await apiRequest(
|
||||||
|
fetch,
|
||||||
|
`${API_BASE_PREFIX}/change_log?id=eq.${data.get("id")}&select=old_value,new_value`,
|
||||||
|
"GET",
|
||||||
|
{
|
||||||
|
headers: { Accept: "application/vnd.pgrst.object+json;nulls=stripped" },
|
||||||
|
returnData: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
logId: data.get("id"),
|
||||||
|
currentDiff: htmlDiff(
|
||||||
|
JSON.stringify(log.old_value, null, 2),
|
||||||
|
JSON.stringify(log.new_value, null, 2)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,62 +2,46 @@
|
|||||||
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
|
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
|
||||||
import DateTime from "$lib/components/DateTime.svelte";
|
import DateTime from "$lib/components/DateTime.svelte";
|
||||||
import Modal from "$lib/components/Modal.svelte";
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
import type { PageServerData } from "./$types";
|
import type { PageServerData, ActionData } from "./$types";
|
||||||
import diff from "fast-diff";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { tables } from "$lib/db-schema";
|
import { tables } from "$lib/db-schema";
|
||||||
import { previewContent } from "$lib/runes.svelte";
|
import { previewContent } from "$lib/runes.svelte";
|
||||||
import { sanitize } from "isomorphic-dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
|
import { enhanceForm } from "$lib/utils";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import { sending } from "$lib/runes.svelte";
|
||||||
|
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
|
||||||
|
import Pagination from "$lib/components/Pagination.svelte";
|
||||||
|
|
||||||
const { data }: { data: PageServerData } = $props();
|
const { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
|
||||||
const htmlDiff = (oldValue: string, newValue: string) => {
|
|
||||||
return diff(oldValue, newValue)
|
|
||||||
.map(([type, value]) => {
|
|
||||||
let newString = "";
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 1:
|
|
||||||
newString += `<ins>${value}</ins>`;
|
|
||||||
break;
|
|
||||||
case 0:
|
|
||||||
newString += `${value}`;
|
|
||||||
break;
|
|
||||||
case -1:
|
|
||||||
newString += `<del>${value}</del>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newString;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
};
|
|
||||||
|
|
||||||
let resources = $state({});
|
let resources = $state({});
|
||||||
|
|
||||||
if (data.website.content_type === "Blog") {
|
if (data.website.content_type === "Blog") {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { user, change_log, media, docs_category, ...restTables } = tables;
|
const { user, change_log, docs_category, ...restTables } = tables;
|
||||||
resources = restTables;
|
resources = restTables;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.website.content_type === "Docs") {
|
if (data.website.content_type === "Docs") {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { user, change_log, media, ...restTables } = tables;
|
const { user, change_log, ...restTables } = tables;
|
||||||
resources = restTables;
|
resources = restTables;
|
||||||
}
|
}
|
||||||
|
|
||||||
previewContent.value = data.home.main_content;
|
previewContent.value = data.home.main_content;
|
||||||
|
|
||||||
let logsSection: HTMLElement;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if sending.value}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<WebsiteEditor
|
<WebsiteEditor
|
||||||
id={data.website.id}
|
id={data.website.id}
|
||||||
contentType={data.website.content_type}
|
contentType={data.website.content_type}
|
||||||
title={data.website.title}
|
title={data.website.title}
|
||||||
>
|
>
|
||||||
<section id="logs" bind:this={logsSection}>
|
<section id="logs">
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h2>
|
<h2>
|
||||||
<a href="#logs">Logs</a>
|
<a href="#logs">Logs</a>
|
||||||
@@ -74,8 +58,8 @@
|
|||||||
Username:
|
Username:
|
||||||
<input
|
<input
|
||||||
list="users-{data.website.id}"
|
list="users-{data.website.id}"
|
||||||
name="logs_filter_user"
|
name="user"
|
||||||
value={$page.url.searchParams.get("logs_filter_user")}
|
value={$page.url.searchParams.get("user")}
|
||||||
/>
|
/>
|
||||||
<datalist id="users-{data.website.id}">
|
<datalist id="users-{data.website.id}">
|
||||||
<option value={data.website.user.username}></option>
|
<option value={data.website.user.username}></option>
|
||||||
@@ -86,40 +70,33 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Resource:
|
Resource:
|
||||||
<select name="logs_filter_resource">
|
<select name="resource">
|
||||||
<option value="all">Show all</option>
|
<option value="all">Show all</option>
|
||||||
{#each Object.keys(resources) as resource}
|
{#each Object.keys(resources) as resource}
|
||||||
<option
|
<option
|
||||||
value={resource}
|
value={resource}
|
||||||
selected={resource === $page.url.searchParams.get("logs_filter_resource")}
|
selected={resource === $page.url.searchParams.get("resource")}>{resource}</option
|
||||||
>{resource}</option
|
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Operation:
|
Operation:
|
||||||
<select name="logs_filter_operation">
|
<select name="operation">
|
||||||
<option value="all">Show all</option>
|
<option value="all">Show all</option>
|
||||||
<option
|
<option value="insert" selected={"insert" === $page.url.searchParams.get("operation")}
|
||||||
value="insert"
|
|
||||||
selected={"insert" === $page.url.searchParams.get("logs_filter_operation")}
|
|
||||||
>Insert</option
|
>Insert</option
|
||||||
>
|
>
|
||||||
<option
|
<option value="update" selected={"update" === $page.url.searchParams.get("operation")}
|
||||||
value="update"
|
|
||||||
selected={"update" === $page.url.searchParams.get("logs_filter_operation")}
|
|
||||||
>Update</option
|
>Update</option
|
||||||
>
|
>
|
||||||
<option
|
<option value="delete" selected={"delete" === $page.url.searchParams.get("operation")}
|
||||||
value="delete"
|
|
||||||
selected={"delete" === $page.url.searchParams.get("logs_filter_operation")}
|
|
||||||
>Delete</option
|
>Delete</option
|
||||||
>
|
>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<input type="hidden" name="logs_results_page" value={1} />
|
<input type="hidden" name="page" value={1} />
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Apply</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
<div class="scroll-container">
|
<div class="scroll-container">
|
||||||
@@ -129,7 +106,7 @@
|
|||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Resource</th>
|
<th>Resource</th>
|
||||||
<th>Operation</th>
|
<th>Operation</th>
|
||||||
<th>Date and time</th>
|
<th>Date & Time</th>
|
||||||
<th>Changes</th>
|
<th>Changes</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -153,12 +130,32 @@
|
|||||||
|
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h3>Log changes</h3>
|
<h3>Log changes</h3>
|
||||||
<p>{table_name} — {operation}</p>
|
<p>{table_name} — {operation} — User "{username}"</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
|
||||||
<pre style="white-space: pre-wrap">{@html sanitize(htmlDiff(oldValue, newValue), {
|
{#if old_value && new_value}
|
||||||
ALLOWED_TAGS: ["ins", "del"]
|
<h4>Difference</h4>
|
||||||
})}</pre>
|
<form action="?/computeDiff" method="POST" use:enhance={enhanceForm()}>
|
||||||
|
<input type="hidden" name="id" value={id} />
|
||||||
|
<button type="submit">Compute diff</button>
|
||||||
|
</form>
|
||||||
|
{#if form?.logId === id && form?.currentDiff}
|
||||||
|
<pre style="white-space: pre-wrap">{@html DOMPurify.sanitize(
|
||||||
|
form.currentDiff,
|
||||||
|
{ ALLOWED_TAGS: ["ins", "del"] }
|
||||||
|
)}</pre>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if new_value && !old_value}
|
||||||
|
<h4>New value</h4>
|
||||||
|
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(newValue)}</pre>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if old_value && !new_value}
|
||||||
|
<h4>Old value</h4>
|
||||||
|
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(oldValue)}</pre>
|
||||||
|
{/if}
|
||||||
</Modal>
|
</Modal>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -166,102 +163,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<Pagination
|
||||||
{#snippet commonFilterInputs()}
|
commonFilters={["user", "resource", "operation"]}
|
||||||
<input
|
resultCount={data.resultChangeLogCount}
|
||||||
type="hidden"
|
/>
|
||||||
name="logs_filter_user"
|
|
||||||
value={$page.url.searchParams.get("logs_filter_user")}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="logs_filter_resource"
|
|
||||||
value={$page.url.searchParams.get("logs_filter_resource")}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="logs_filter_operation"
|
|
||||||
value={$page.url.searchParams.get("logs_filter_operation")}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
<p>
|
|
||||||
{$page.url.searchParams.get("logs_results_page") ?? 1} / {Math.max(
|
|
||||||
Math.ceil(data.resultChangeLogCount / 50),
|
|
||||||
1
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<form method="GET">
|
|
||||||
<input type="hidden" name="logs_results_page" value={1} />
|
|
||||||
{@render commonFilterInputs()}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}>First</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
<form method="GET">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="logs_results_page"
|
|
||||||
value={Math.max(
|
|
||||||
1,
|
|
||||||
Number.parseInt($page.url.searchParams.get("logs_results_page") ?? "1") - 1
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{@render commonFilterInputs()}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}
|
|
||||||
>Previous</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
<form method="GET">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="logs_results_page"
|
|
||||||
value={Math.min(
|
|
||||||
Math.max(Math.ceil(data.resultChangeLogCount / 50), 1),
|
|
||||||
Number.parseInt($page.url.searchParams.get("logs_results_page") ?? "1") + 1
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{@render commonFilterInputs()}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") ===
|
|
||||||
Math.max(Math.ceil(data.resultChangeLogCount / 50), 1).toString()}>Next</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
<form method="GET">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="logs_results_page"
|
|
||||||
value={Math.max(Math.ceil(data.resultChangeLogCount / 50), 1)}
|
|
||||||
/>
|
|
||||||
{@render commonFilterInputs()}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") ===
|
|
||||||
Math.max(Math.ceil(data.resultChangeLogCount / 50), 1).toString()}>Last</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</WebsiteEditor>
|
</WebsiteEditor>
|
||||||
|
|
||||||
<style>
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
justify-content: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination > form:first-of-type {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
color: hsl(0 0% 50%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import BlogIndex from "$lib/templates/blog/BlogIndex.svelte";
|
|||||||
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
|
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
|
||||||
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
|
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
|
||||||
import { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils";
|
import { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils";
|
||||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rename, writeFile, chmod, readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { render } from "svelte/server";
|
import { render } from "svelte/server";
|
||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
|
||||||
const websiteOverview: WebsiteOverview = (
|
const websiteOverview: WebsiteOverview = (
|
||||||
await apiRequest(
|
await apiRequest(
|
||||||
fetch,
|
fetch,
|
||||||
@@ -25,29 +25,15 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
|||||||
)
|
)
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
generateStaticFiles(websiteOverview);
|
const { websitePreviewUrl, websiteProdUrl } = await generateStaticFiles(websiteOverview);
|
||||||
|
|
||||||
const websitePreviewUrl = `${
|
const { permissionLevel } = await parent();
|
||||||
dev
|
|
||||||
? "http://localhost:18000"
|
|
||||||
: process.env.ORIGIN
|
|
||||||
? process.env.ORIGIN
|
|
||||||
: "http://localhost:18000"
|
|
||||||
}/previews/${websiteOverview.id}/`;
|
|
||||||
|
|
||||||
const websiteProdUrl = dev
|
|
||||||
? `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`
|
|
||||||
: process.env.ORIGIN
|
|
||||||
? process.env.ORIGIN.replace(
|
|
||||||
"//",
|
|
||||||
`//${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}.`
|
|
||||||
)
|
|
||||||
: `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
websitePreviewUrl,
|
websitePreviewUrl,
|
||||||
websiteProdUrl
|
websiteProdUrl,
|
||||||
|
permissionLevel
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +53,7 @@ export const actions: Actions = {
|
|||||||
)
|
)
|
||||||
).data;
|
).data;
|
||||||
|
|
||||||
generateStaticFiles(websiteOverview, false);
|
await generateStaticFiles(websiteOverview, false);
|
||||||
|
|
||||||
return await apiRequest(
|
return await apiRequest(
|
||||||
fetch,
|
fetch,
|
||||||
@@ -156,6 +142,23 @@ export const actions: Actions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = true) => {
|
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = true) => {
|
||||||
|
const websitePreviewUrl = `${
|
||||||
|
dev
|
||||||
|
? "http://localhost:18000"
|
||||||
|
: process.env.ORIGIN
|
||||||
|
? process.env.ORIGIN
|
||||||
|
: "http://localhost:18000"
|
||||||
|
}/previews/${websiteData.id}/`;
|
||||||
|
|
||||||
|
const websiteProdUrl = dev
|
||||||
|
? `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`
|
||||||
|
: process.env.ORIGIN
|
||||||
|
? process.env.ORIGIN.replace(
|
||||||
|
"//",
|
||||||
|
`//${websiteData.domain_prefix?.prefix ?? websiteData.id}.`
|
||||||
|
)
|
||||||
|
: `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`;
|
||||||
|
|
||||||
const fileContents = (head: string, body: string) => {
|
const fileContents = (head: string, body: string) => {
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -173,7 +176,8 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
|
|||||||
props: {
|
props: {
|
||||||
websiteOverview: websiteData,
|
websiteOverview: websiteData,
|
||||||
apiUrl: API_BASE_PREFIX,
|
apiUrl: API_BASE_PREFIX,
|
||||||
isLegalPage: false
|
isLegalPage: false,
|
||||||
|
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +206,8 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
|
|||||||
props: {
|
props: {
|
||||||
websiteOverview: websiteData,
|
websiteOverview: websiteData,
|
||||||
article,
|
article,
|
||||||
apiUrl: API_BASE_PREFIX
|
apiUrl: API_BASE_PREFIX,
|
||||||
|
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,13 +222,17 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
|
|||||||
props: {
|
props: {
|
||||||
websiteOverview: websiteData,
|
websiteOverview: websiteData,
|
||||||
apiUrl: API_BASE_PREFIX,
|
apiUrl: API_BASE_PREFIX,
|
||||||
isLegalPage: true
|
isLegalPage: true,
|
||||||
|
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
|
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, {
|
||||||
|
encoding: "utf-8"
|
||||||
|
});
|
||||||
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
|
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
|
||||||
encoding: "utf-8"
|
encoding: "utf-8"
|
||||||
});
|
});
|
||||||
@@ -246,22 +255,58 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
|
|||||||
} = hexToHSL(websiteData.settings.background_color_light_theme);
|
} = hexToHSL(websiteData.settings.background_color_light_theme);
|
||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(uploadDir, "styles.css"),
|
join(uploadDir, "variables.css"),
|
||||||
commonStyles
|
variableStyles
|
||||||
.concat(specificStyles)
|
.replaceAll(
|
||||||
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_H \*\/\s*).*(?=;)/, ` ${hDark}`)
|
/\/\* BACKGROUND_COLOR_DARK_THEME_H \*\/\s*.*?;/g,
|
||||||
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_S \*\/\s*).*(?=;)/, ` ${sDark}%`)
|
`/* BACKGROUND_COLOR_DARK_THEME_H */ ${hDark};`
|
||||||
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_L \*\/\s*).*(?=;)/, ` ${lDark}%`)
|
|
||||||
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_H \*\/\s*).*(?=;)/, ` ${hLight}`)
|
|
||||||
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_S \*\/\s*).*(?=;)/, ` ${sLight}%`)
|
|
||||||
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_L \*\/\s*).*(?=;)/, ` ${lLight}%`)
|
|
||||||
.replace(
|
|
||||||
/(?<=\/\* ACCENT_COLOR_DARK_THEME \*\/\s*).*(?=;)/,
|
|
||||||
` ${websiteData.settings.accent_color_dark_theme}`
|
|
||||||
)
|
)
|
||||||
.replace(
|
.replaceAll(
|
||||||
/(?<=\/\* ACCENT_COLOR_LIGHT_THEME \*\/\s*).*(?=;)/,
|
/\/\* BACKGROUND_COLOR_DARK_THEME_S \*\/\s*.*?;/g,
|
||||||
` ${websiteData.settings.accent_color_light_theme}`
|
`/* BACKGROUND_COLOR_DARK_THEME_S */ ${sDark}%;`
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
/\/\* BACKGROUND_COLOR_DARK_THEME_L \*\/\s*.*?;/g,
|
||||||
|
`/* BACKGROUND_COLOR_DARK_THEME_L */ ${lDark}%;`
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
/\/\* BACKGROUND_COLOR_LIGHT_THEME_H \*\/\s*.*?;/g,
|
||||||
|
`/* BACKGROUND_COLOR_LIGHT_THEME_H */ ${hLight};`
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
/\/\* BACKGROUND_COLOR_LIGHT_THEME_S \*\/\s*.*?;/g,
|
||||||
|
`/* BACKGROUND_COLOR_LIGHT_THEME_S */ ${sLight}%;`
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
/\/\* BACKGROUND_COLOR_LIGHT_THEME_L \*\/\s*.*?;/g,
|
||||||
|
`/* BACKGROUND_COLOR_LIGHT_THEME_L */ ${lLight}%;`
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
/\/\* ACCENT_COLOR_DARK_THEME \*\/\s*.*?;/g,
|
||||||
|
`/* ACCENT_COLOR_DARK_THEME */ ${websiteData.settings.accent_color_dark_theme};`
|
||||||
|
)
|
||||||
|
.replaceAll(
|
||||||
|
/\/\* ACCENT_COLOR_LIGHT_THEME \*\/\s*.*?;/g,
|
||||||
|
`/* ACCENT_COLOR_LIGHT_THEME */ ${websiteData.settings.accent_color_light_theme};`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
await writeFile(join(uploadDir, "common.css"), commonStyles);
|
||||||
|
await writeFile(join(uploadDir, "scoped.css"), specificStyles);
|
||||||
|
|
||||||
|
await setPermissions(isPreview ? join(uploadDir, "../") : uploadDir);
|
||||||
|
|
||||||
|
return { websitePreviewUrl, websiteProdUrl };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPermissions = async (dir: string) => {
|
||||||
|
await chmod(dir, 0o777);
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await setPermissions(fullPath);
|
||||||
|
} else {
|
||||||
|
await chmod(fullPath, 0o777);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
be published on the Internet.
|
be published on the Internet.
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
|
<form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
|
||||||
<button type="submit">Publish</button>
|
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
|
||||||
|
>Publish website</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -46,8 +48,7 @@
|
|||||||
<a href="#publication-status">Publication status</a>
|
<a href="#publication-status">Publication status</a>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Your website is published at:
|
Your website is published at:<br />
|
||||||
<br />
|
|
||||||
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
|
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -74,7 +75,9 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
|
||||||
|
>Update domain prefix</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
{#if data.websiteOverview.domain_prefix?.prefix}
|
{#if data.websiteOverview.domain_prefix?.prefix}
|
||||||
<Modal id="delete-domain-prefix" text="Delete">
|
<Modal id="delete-domain-prefix" text="Delete">
|
||||||
@@ -88,7 +91,9 @@
|
|||||||
<strong>Caution!</strong>
|
<strong>Caution!</strong>
|
||||||
This action will remove the domain prefix and reset it to its initial value.
|
This action will remove the domain prefix and reset it to its initial value.
|
||||||
</p>
|
</p>
|
||||||
<button type="submit">Delete domain prefix</button>
|
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
|
||||||
|
>Delete domain prefix</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import "../../template-styles/variables.css";
|
||||||
import "../../template-styles/common-styles.css";
|
import "../../template-styles/common-styles.css";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import type { LayoutServerData } from "./$types";
|
import type { LayoutServerData } from "./$types";
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
let loadingDelay: number;
|
let loadingDelay: number;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($navigating && ["link", "goto"].includes($navigating.type)) {
|
if ($navigating) {
|
||||||
loadingDelay = window.setTimeout(() => (loading = true), LOADING_DELAY);
|
loadingDelay = window.setTimeout(() => (loading = true), LOADING_DELAY);
|
||||||
} else {
|
} else {
|
||||||
window.clearTimeout(loadingDelay);
|
window.clearTimeout(loadingDelay);
|
||||||
@@ -52,6 +53,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<ul class="link-wrapper unpadded">
|
<ul class="link-wrapper unpadded">
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
|
{#if data.user.user_role === "administrator"}
|
||||||
|
<li>
|
||||||
|
<a href="/manage">Manage</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
<li>
|
<li>
|
||||||
<a href="/account">Account</a>
|
<a href="/account">Account</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ nav {
|
|||||||
border-block-end: var(--border-primary);
|
border-block-end: var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav > .container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
header > .container {
|
header > .container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -33,6 +39,11 @@ footer {
|
|||||||
padding-block: var(--space-s);
|
padding-block: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-block-start: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.articles ul {
|
.articles ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,104 +1,3 @@
|
|||||||
@import url("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css")
|
|
||||||
screen and (prefers-color-scheme: light);
|
|
||||||
@import url("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github-dark.min.css")
|
|
||||||
screen and (prefers-color-scheme: dark);
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "JetBrains Mono";
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
url(https://cdn.jsdelivr.net/fontsource/fonts/jetbrains-mono@latest/latin-400-normal.woff2)
|
|
||||||
format("woff2"),
|
|
||||||
url(https://cdn.jsdelivr.net/fontsource/fonts/jetbrains-mono@latest/latin-400-normal.woff)
|
|
||||||
format("woff");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
|
||||||
U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
|
|
||||||
U+FFFD;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
|
|
||||||
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
|
|
||||||
--bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
|
|
||||||
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
|
|
||||||
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
|
|
||||||
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
|
|
||||||
--bg-blurred: hsla(
|
|
||||||
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 20%)
|
|
||||||
);
|
|
||||||
|
|
||||||
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
|
|
||||||
--color-text-invert: var(--bg-primary);
|
|
||||||
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
|
|
||||||
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
|
|
||||||
--color-success: hsl(105 100% 30%);
|
|
||||||
--color-error: hsl(0 100% 30%);
|
|
||||||
|
|
||||||
--border-primary: 0.0625rem solid var(--color-border);
|
|
||||||
--border-radius: 0.125rem;
|
|
||||||
|
|
||||||
/* Step -1: 14.9953px → 14.2222px */
|
|
||||||
--font-size--1: clamp(0.8889rem, 0.9592rem + -0.1098cqi, 0.9372rem);
|
|
||||||
/* Step 0: 16px → 16px */
|
|
||||||
--font-size-0: clamp(1rem, 1rem + 0cqi, 1rem);
|
|
||||||
/* Step 1: 17.072px → 18px */
|
|
||||||
--font-size-1: clamp(1.067rem, 1.0406rem + 0.1318cqi, 1.125rem);
|
|
||||||
/* Step 2: 18.2158px → 20.25px */
|
|
||||||
--font-size-2: clamp(1.1385rem, 1.0807rem + 0.2889cqi, 1.2656rem);
|
|
||||||
/* Step 3: 19.4363px → 22.7813px */
|
|
||||||
--font-size-3: clamp(1.2148rem, 1.1197rem + 0.4751cqi, 1.4238rem);
|
|
||||||
/* Step 4: 20.7385px → 25.6289px */
|
|
||||||
--font-size-4: clamp(1.2962rem, 1.1572rem + 0.6947cqi, 1.6018rem);
|
|
||||||
/* Step 5: 22.128px → 28.8325px */
|
|
||||||
--font-size-5: clamp(1.383rem, 1.1925rem + 0.9523cqi, 1.802rem);
|
|
||||||
|
|
||||||
/* Space 3xs: 4px → 5px */
|
|
||||||
--space-3xs: clamp(0.25rem, 0.2336rem + 0.0822cqi, 0.3125rem);
|
|
||||||
/* Space 2xs: 8px → 10px */
|
|
||||||
--space-2xs: clamp(0.5rem, 0.4671rem + 0.1645cqi, 0.625rem);
|
|
||||||
/* Space xs: 12px → 15px */
|
|
||||||
--space-xs: clamp(0.75rem, 0.7007rem + 0.2467cqi, 0.9375rem);
|
|
||||||
/* Space s: 16px → 20px */
|
|
||||||
--space-s: clamp(1rem, 0.9342rem + 0.3289cqi, 1.25rem);
|
|
||||||
/* Space m: 24px → 30px */
|
|
||||||
--space-m: clamp(1.5rem, 1.4013rem + 0.4934cqi, 1.875rem);
|
|
||||||
/* Space l: 32px → 40px */
|
|
||||||
--space-l: clamp(2rem, 1.8684rem + 0.6579cqi, 2.5rem);
|
|
||||||
/* Space xl: 48px → 60px */
|
|
||||||
--space-xl: clamp(3rem, 2.8026rem + 0.9868cqi, 3.75rem);
|
|
||||||
/* Space 2xl: 64px → 80px */
|
|
||||||
--space-2xl: clamp(4rem, 3.7368rem + 1.3158cqi, 5rem);
|
|
||||||
/* Space 3xl: 96px → 120px */
|
|
||||||
--space-3xl: clamp(6rem, 5.6053rem + 1.9737cqi, 7.5rem);
|
|
||||||
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
|
|
||||||
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
|
|
||||||
--bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
|
|
||||||
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
|
|
||||||
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
|
|
||||||
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
|
|
||||||
--bg-blurred: hsla(
|
|
||||||
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 20%)
|
|
||||||
);
|
|
||||||
|
|
||||||
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
|
|
||||||
--color-text-invert: var(--bg-primary);
|
|
||||||
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
|
|
||||||
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
|
|
||||||
--color-success: hsl(105 100% 80%);
|
|
||||||
--color-error: hsl(0 100% 80%);
|
|
||||||
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -109,11 +8,12 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-block-size: 100vh;
|
min-block-size: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -123,6 +23,7 @@ select,
|
|||||||
[role="option"],
|
[role="option"],
|
||||||
label[for="toggle-mobile-preview"],
|
label[for="toggle-mobile-preview"],
|
||||||
label[for="toggle-sidebar"],
|
label[for="toggle-sidebar"],
|
||||||
|
label[for="toggle-theme"],
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -131,9 +32,11 @@ input,
|
|||||||
button,
|
button,
|
||||||
textarea,
|
textarea,
|
||||||
select,
|
select,
|
||||||
|
input[type="file"]::file-selector-button,
|
||||||
a[role="button"],
|
a[role="button"],
|
||||||
label[for="toggle-mobile-preview"],
|
label[for="toggle-mobile-preview"],
|
||||||
label[for="toggle-sidebar"],
|
label[for="toggle-sidebar"],
|
||||||
|
label[for="toggle-theme"],
|
||||||
summary {
|
summary {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -157,6 +60,11 @@ input[type="file"] {
|
|||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
|
padding-block: calc(var(--space-3xs) / 4);
|
||||||
|
margin-inline-end: var(--space-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
input[type="color"] {
|
input[type="color"] {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -171,23 +79,45 @@ summary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
input[type="file"]::file-selector-button,
|
||||||
a[role="button"],
|
a[role="button"],
|
||||||
label[for="toggle-mobile-preview"],
|
label[for="toggle-mobile-preview"],
|
||||||
label[for="toggle-sidebar"],
|
label[for="toggle-sidebar"],
|
||||||
|
label[for="toggle-theme"],
|
||||||
summary {
|
summary {
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label:has(svg) {
|
||||||
|
display: inline-grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
label[for="toggle-theme"] svg:first-of-type {
|
||||||
|
display: var(--display-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
label[for="toggle-theme"] svg:last-of-type {
|
||||||
|
display: var(--display-dark);
|
||||||
|
}
|
||||||
|
|
||||||
:is(
|
:is(
|
||||||
button,
|
button,
|
||||||
a[role="button"],
|
a[role="button"],
|
||||||
label[for="toggle-mobile-preview"],
|
label[for="toggle-mobile-preview"],
|
||||||
label[for="toggle-sidebar"],
|
label[for="toggle-sidebar"],
|
||||||
|
label[for="toggle-theme"],
|
||||||
summary
|
summary
|
||||||
):hover {
|
):hover {
|
||||||
background-color: var(--bg-tertiary);
|
background-color: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: -10;
|
||||||
|
}
|
||||||
|
|
||||||
:is(button, input, textarea, select, a, summary, pre):focus,
|
:is(button, input, textarea, select, a, summary, pre):focus,
|
||||||
:is(#toggle-mobile-preview, #toggle-sidebar):checked + label {
|
:is(#toggle-mobile-preview, #toggle-sidebar):checked + label {
|
||||||
outline: 0.125rem solid var(--color-accent);
|
outline: 0.125rem solid var(--color-accent);
|
||||||
@@ -303,12 +233,14 @@ pre {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code,
|
||||||
font-family: "JetBrains Mono", monospace;
|
kbd {
|
||||||
|
font-family: monospace;
|
||||||
font-size: var(--font-size--1);
|
font-size: var(--font-size--1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:not(pre) > code {
|
:not(pre) > code,
|
||||||
|
kbd {
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border: var(--border-primary);
|
border: var(--border-primary);
|
||||||
padding-inline: var(--space-3xs);
|
padding-inline: var(--space-3xs);
|
||||||
@@ -354,3 +286,105 @@ del {
|
|||||||
background-color: var(--color-error);
|
background-color: var(--color-error);
|
||||||
color: var(--color-text-invert);
|
color: var(--color-text-invert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-inline-start: var(--border-primary);
|
||||||
|
border-width: 0.25rem;
|
||||||
|
padding-inline-start: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
meter {
|
||||||
|
inline-size: min(512px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
color: var(--hl-color);
|
||||||
|
background: var(--hl-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-meta .hljs-keyword,
|
||||||
|
.hljs-template-tag,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-variable.language_ {
|
||||||
|
color: var(--hl-keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-title,
|
||||||
|
.hljs-title.class_,
|
||||||
|
.hljs-title.class_.inherited__,
|
||||||
|
.hljs-title.function_ {
|
||||||
|
color: var(--hl-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-attribute,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-operator,
|
||||||
|
.hljs-selector-attr,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-variable {
|
||||||
|
color: var(--hl-attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-meta .hljs-string,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-string {
|
||||||
|
color: var(--hl-string);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-symbol {
|
||||||
|
color: var(--hl-built-in);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-code,
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-formula {
|
||||||
|
color: var(--hl-comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-quote,
|
||||||
|
.hljs-selector-pseudo,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
color: var(--hl-tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-subst {
|
||||||
|
color: var(--hl-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-section {
|
||||||
|
color: var(--hl-section);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-bullet {
|
||||||
|
color: var(--hl-bullet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-emphasis {
|
||||||
|
color: var(--hl-emphasis);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
color: var(--hl-emphasis);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-addition {
|
||||||
|
color: var(--hl-addition-text);
|
||||||
|
background-color: var(--hl-addition-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion {
|
||||||
|
color: var(--hl-deletion-text);
|
||||||
|
background-color: var(--hl-deletion-bg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ footer {
|
|||||||
padding-block: var(--space-s);
|
padding-block: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
footer {
|
||||||
scroll-margin-block-start: var(--space-xl);
|
margin-block-start: auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
label[for="toggle-sidebar"] {
|
section {
|
||||||
display: inline-grid;
|
scroll-margin-block-start: var(--space-xl);
|
||||||
place-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-navigation {
|
.docs-navigation {
|
||||||
|
|||||||
204
web-app/template-styles/variables.css
Normal file
204
web-app/template-styles/variables.css
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
html {
|
||||||
|
--border-primary: 0.0625rem solid var(--color-border);
|
||||||
|
--border-radius: 0.125rem;
|
||||||
|
|
||||||
|
/* Step -1: 14.9953px → 14.2222px */
|
||||||
|
--font-size--1: clamp(0.8889rem, 0.9592rem + -0.1098cqi, 0.9372rem);
|
||||||
|
/* Step 0: 16px → 16px */
|
||||||
|
--font-size-0: clamp(1rem, 1rem + 0cqi, 1rem);
|
||||||
|
/* Step 1: 17.072px → 18px */
|
||||||
|
--font-size-1: clamp(1.067rem, 1.0406rem + 0.1318cqi, 1.125rem);
|
||||||
|
/* Step 2: 18.2158px → 20.25px */
|
||||||
|
--font-size-2: clamp(1.1385rem, 1.0807rem + 0.2889cqi, 1.2656rem);
|
||||||
|
/* Step 3: 19.4363px → 22.7813px */
|
||||||
|
--font-size-3: clamp(1.2148rem, 1.1197rem + 0.4751cqi, 1.4238rem);
|
||||||
|
/* Step 4: 20.7385px → 25.6289px */
|
||||||
|
--font-size-4: clamp(1.2962rem, 1.1572rem + 0.6947cqi, 1.6018rem);
|
||||||
|
/* Step 5: 22.128px → 28.8325px */
|
||||||
|
--font-size-5: clamp(1.383rem, 1.1925rem + 0.9523cqi, 1.802rem);
|
||||||
|
|
||||||
|
/* Space 3xs: 4px → 5px */
|
||||||
|
--space-3xs: clamp(0.25rem, 0.2336rem + 0.0822cqi, 0.3125rem);
|
||||||
|
/* Space 2xs: 8px → 10px */
|
||||||
|
--space-2xs: clamp(0.5rem, 0.4671rem + 0.1645cqi, 0.625rem);
|
||||||
|
/* Space xs: 12px → 15px */
|
||||||
|
--space-xs: clamp(0.75rem, 0.7007rem + 0.2467cqi, 0.9375rem);
|
||||||
|
/* Space s: 16px → 20px */
|
||||||
|
--space-s: clamp(1rem, 0.9342rem + 0.3289cqi, 1.25rem);
|
||||||
|
/* Space m: 24px → 30px */
|
||||||
|
--space-m: clamp(1.5rem, 1.4013rem + 0.4934cqi, 1.875rem);
|
||||||
|
/* Space l: 32px → 40px */
|
||||||
|
--space-l: clamp(2rem, 1.8684rem + 0.6579cqi, 2.5rem);
|
||||||
|
/* Space xl: 48px → 60px */
|
||||||
|
--space-xl: clamp(3rem, 2.8026rem + 0.9868cqi, 3.75rem);
|
||||||
|
/* Space 2xl: 64px → 80px */
|
||||||
|
--space-2xl: clamp(4rem, 3.7368rem + 1.3158cqi, 5rem);
|
||||||
|
/* Space 3xl: 96px → 120px */
|
||||||
|
--space-3xl: clamp(6rem, 5.6053rem + 1.9737cqi, 7.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
|
||||||
|
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
|
||||||
|
--bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
|
||||||
|
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
|
||||||
|
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
|
||||||
|
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
|
||||||
|
--bg-blurred: hsla(
|
||||||
|
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 50%)
|
||||||
|
);
|
||||||
|
|
||||||
|
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
|
||||||
|
--color-text-invert: var(--bg-primary);
|
||||||
|
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
|
||||||
|
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
|
||||||
|
--color-success: hsl(105 100% 30%);
|
||||||
|
--color-error: hsl(0 100% 30%);
|
||||||
|
|
||||||
|
--display-light: none;
|
||||||
|
--display-dark: initial;
|
||||||
|
|
||||||
|
--hl-bg: #fff;
|
||||||
|
--hl-color: #24292e;
|
||||||
|
--hl-keyword: #d73a49;
|
||||||
|
--hl-title: #6f42c1;
|
||||||
|
--hl-attr: #005cc5;
|
||||||
|
--hl-string: #032f62;
|
||||||
|
--hl-built-in: #e36209;
|
||||||
|
--hl-comment: #6a737d;
|
||||||
|
--hl-tag: #22863a;
|
||||||
|
--hl-section: #005cc5;
|
||||||
|
--hl-bullet: #735c0f;
|
||||||
|
--hl-emphasis: #24292e;
|
||||||
|
--hl-addition-bg: #f0fff4;
|
||||||
|
--hl-addition-text: #22863a;
|
||||||
|
--hl-deletion-bg: #ffeef0;
|
||||||
|
--hl-deletion-text: #b31d28;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:has(#toggle-theme:checked) {
|
||||||
|
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
|
||||||
|
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
|
||||||
|
--bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
|
||||||
|
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
|
||||||
|
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
|
||||||
|
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
|
||||||
|
--bg-blurred: hsla(
|
||||||
|
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 50%)
|
||||||
|
);
|
||||||
|
|
||||||
|
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
|
||||||
|
--color-text-invert: var(--bg-primary);
|
||||||
|
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
|
||||||
|
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
|
||||||
|
--color-success: hsl(105 100% 80%);
|
||||||
|
--color-error: hsl(0 100% 80%);
|
||||||
|
|
||||||
|
--display-light: initial;
|
||||||
|
--display-dark: none;
|
||||||
|
|
||||||
|
--hl-bg: #0d1117;
|
||||||
|
--hl-color: #c9d1d9;
|
||||||
|
--hl-keyword: #ff7b72;
|
||||||
|
--hl-title: #d2a8ff;
|
||||||
|
--hl-attr: #79c0ff;
|
||||||
|
--hl-string: #a5d6ff;
|
||||||
|
--hl-built-in: #ffa657;
|
||||||
|
--hl-comment: #8b949e;
|
||||||
|
--hl-tag: #7ee787;
|
||||||
|
--hl-section: #1f6feb;
|
||||||
|
--hl-bullet: #f2cc60;
|
||||||
|
--hl-emphasis: #c9d1d9;
|
||||||
|
--hl-addition-bg: #033a16;
|
||||||
|
--hl-addition-text: #aff5b4;
|
||||||
|
--hl-deletion-bg: #67060c;
|
||||||
|
--hl-deletion-text: #ffdcd7;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
|
||||||
|
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
|
||||||
|
--bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
|
||||||
|
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
|
||||||
|
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
|
||||||
|
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
|
||||||
|
--bg-blurred: hsla(
|
||||||
|
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 50%)
|
||||||
|
);
|
||||||
|
|
||||||
|
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
|
||||||
|
--color-text-invert: var(--bg-primary);
|
||||||
|
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
|
||||||
|
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
|
||||||
|
--color-success: hsl(105 100% 80%);
|
||||||
|
--color-error: hsl(0 100% 80%);
|
||||||
|
|
||||||
|
--display-light: initial;
|
||||||
|
--display-dark: none;
|
||||||
|
|
||||||
|
--hl-bg: #0d1117;
|
||||||
|
--hl-color: #c9d1d9;
|
||||||
|
--hl-keyword: #ff7b72;
|
||||||
|
--hl-title: #d2a8ff;
|
||||||
|
--hl-attr: #79c0ff;
|
||||||
|
--hl-string: #a5d6ff;
|
||||||
|
--hl-built-in: #ffa657;
|
||||||
|
--hl-comment: #8b949e;
|
||||||
|
--hl-tag: #7ee787;
|
||||||
|
--hl-section: #1f6feb;
|
||||||
|
--hl-bullet: #f2cc60;
|
||||||
|
--hl-emphasis: #c9d1d9;
|
||||||
|
--hl-addition-bg: #033a16;
|
||||||
|
--hl-addition-text: #aff5b4;
|
||||||
|
--hl-deletion-bg: #67060c;
|
||||||
|
--hl-deletion-text: #ffdcd7;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:has(#toggle-theme:checked) {
|
||||||
|
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
|
||||||
|
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
|
||||||
|
--bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
|
||||||
|
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
|
||||||
|
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
|
||||||
|
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
|
||||||
|
--bg-blurred: hsla(
|
||||||
|
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 50%)
|
||||||
|
);
|
||||||
|
|
||||||
|
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
|
||||||
|
--color-text-invert: var(--bg-primary);
|
||||||
|
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
|
||||||
|
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
|
||||||
|
--color-success: hsl(105 100% 30%);
|
||||||
|
--color-error: hsl(0 100% 30%);
|
||||||
|
|
||||||
|
--display-light: none;
|
||||||
|
--display-dark: initial;
|
||||||
|
|
||||||
|
--hl-bg: #fff;
|
||||||
|
--hl-color: #24292e;
|
||||||
|
--hl-keyword: #d73a49;
|
||||||
|
--hl-title: #6f42c1;
|
||||||
|
--hl-attr: #005cc5;
|
||||||
|
--hl-string: #032f62;
|
||||||
|
--hl-built-in: #e36209;
|
||||||
|
--hl-comment: #6a737d;
|
||||||
|
--hl-tag: #22863a;
|
||||||
|
--hl-section: #005cc5;
|
||||||
|
--hl-bullet: #735c0f;
|
||||||
|
--hl-emphasis: #24292e;
|
||||||
|
--hl-addition-bg: #f0fff4;
|
||||||
|
--hl-addition-text: #22863a;
|
||||||
|
--hl-deletion-bg: #ffeef0;
|
||||||
|
--hl-deletion-text: #b31d28;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +1,25 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { randomBytes } from "node:crypto";
|
import { userOwner, register, authenticate, password } from "./shared";
|
||||||
|
|
||||||
const username = randomBytes(8).toString("hex");
|
const userDeleted = "test-deleted-a";
|
||||||
const password = "T3stuser??!!";
|
|
||||||
|
|
||||||
const test = base.extend<{ authenticatedPage: Page }>({
|
test(`Logout`, async ({ page }) => {
|
||||||
authenticatedPage: async ({ page }, use) => {
|
await authenticate(userOwner, page);
|
||||||
await page.goto("/login");
|
await page.getByRole("link", { name: "Account" }).click();
|
||||||
await page.getByLabel("Username:").fill(username);
|
await page.getByRole("button", { name: "Logout" }).click();
|
||||||
await page.getByLabel("Password:").fill(password);
|
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await use(page);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.serial("Account tests", () => {
|
test(`Delete account`, async ({ page }) => {
|
||||||
test("Register", async ({ page }) => {
|
await register(userDeleted, page);
|
||||||
await page.goto("/register");
|
await authenticate(userDeleted, page);
|
||||||
await page.getByLabel("Username:").click();
|
await page.getByRole("link", { name: "Account" }).click();
|
||||||
await page.getByLabel("Username:").fill(username);
|
await page.getByRole("button", { name: "Delete account" }).click();
|
||||||
await page.getByLabel("Password:").click();
|
await page.getByLabel("Password:").click();
|
||||||
await page.getByLabel("Password:").fill(password);
|
await page.getByLabel("Password:").fill(password);
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page
|
||||||
await expect(page.getByText("Successfully registered, you")).toBeVisible();
|
.locator("#delete-account-modal")
|
||||||
});
|
.getByRole("button", { name: "Delete account" })
|
||||||
|
.click();
|
||||||
test("Login", async ({ page }) => {
|
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Logout", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Logout" }).click();
|
|
||||||
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Delete account", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
151
web-app/tests/articles.spec.ts
Normal file
151
web-app/tests/articles.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Articles" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Create article`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create article" }).click();
|
||||||
|
await page.locator("#create-article-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
|
||||||
|
await page
|
||||||
|
.locator("#create-article-modal")
|
||||||
|
.getByRole("button", { name: "Create article" })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText("Successfully created article")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "All articles" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Modify", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create article" }).click();
|
||||||
|
await page.locator("#create-article-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
|
||||||
|
await page
|
||||||
|
.locator("#create-article-modal")
|
||||||
|
.getByRole("button", { name: "Create article" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update article`, async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: "Edit" }).first().click();
|
||||||
|
await page.getByLabel("Weight:").click();
|
||||||
|
await page.getByLabel("Weight:").fill("555");
|
||||||
|
await page.getByLabel("Title:").click();
|
||||||
|
await page.getByLabel("Title:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Title:").fill("Example article");
|
||||||
|
await page.getByLabel("Description:").click();
|
||||||
|
await page.getByLabel("Description:").fill("Random description");
|
||||||
|
await page.getByLabel("Author:").click();
|
||||||
|
await page.getByLabel("Author:").fill("John Doe");
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").fill("## Markdown content comes here");
|
||||||
|
await page.getByRole("button", { name: "Update article" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete article`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Delete" }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Delete article" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Articles" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Create article`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create article" }).click();
|
||||||
|
await page.locator("#create-article-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
|
||||||
|
await page
|
||||||
|
.locator("#create-article-modal")
|
||||||
|
.getByRole("button", { name: "Create article" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page
|
||||||
|
.locator("#create-article-modal")
|
||||||
|
.getByRole("button", { name: "Create article" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully created article")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "All articles" })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update article`, async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: "Edit" }).first().click();
|
||||||
|
await page.getByLabel("Weight:").click();
|
||||||
|
await page.getByLabel("Weight:").fill("555");
|
||||||
|
await page.getByLabel("Title:").click();
|
||||||
|
await page.getByLabel("Title:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Title:").fill("Example article");
|
||||||
|
await page.getByLabel("Description:").click();
|
||||||
|
await page.getByLabel("Description:").fill("Random description");
|
||||||
|
await page.getByLabel("Author:").click();
|
||||||
|
await page.getByLabel("Author:").fill("John Doe");
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").fill("## Markdown content comes here");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update article" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update article" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete article`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Delete" }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Delete article" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Delete article" }).click();
|
||||||
|
|
||||||
|
switch (permissionLevel) {
|
||||||
|
case 10:
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
break;
|
||||||
|
case 20:
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText("Successfully deleted article")
|
||||||
|
.or(page.getByText("Insufficient permissions"))
|
||||||
|
).toBeVisible();
|
||||||
|
break;
|
||||||
|
case 30:
|
||||||
|
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
149
web-app/tests/categories.spec.ts
Normal file
149
web-app/tests/categories.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { randomBytes, randomInt } from "node:crypto";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
const genCategoryName = () => randomBytes(12).toString("hex");
|
||||||
|
const genCategoryWeight = (min = 10, max = 10000) => randomInt(min, max).toString();
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Categories" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Create category`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create category" }).click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Name:").click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Name:").fill(genCategoryName());
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Weight:").click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Weight:").fill(genCategoryWeight());
|
||||||
|
await page
|
||||||
|
.locator("#create-category-modal")
|
||||||
|
.getByRole("button", { name: "Create category" })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText("Successfully created category")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "All categories" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Modify", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create category" }).click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Name:").click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Name:").fill(genCategoryName());
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Weight:").click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Weight:").fill(genCategoryWeight());
|
||||||
|
await page
|
||||||
|
.locator("#create-category-modal")
|
||||||
|
.getByRole("button", { name: "Create category" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update category`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Update" }).first().click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Name:").click();
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Name:").fill(genCategoryName());
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Weight:").click();
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Weight:").fill(genCategoryWeight());
|
||||||
|
await page.getByRole("button", { name: "Update category" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated category")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete category`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Delete" }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Delete category" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Categories" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Create category`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create category" }).click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Name:").click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Name:").fill(genCategoryName());
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Weight:").click();
|
||||||
|
await page.locator("#create-category-modal").getByLabel("Weight:").fill(genCategoryWeight());
|
||||||
|
await page
|
||||||
|
.locator("#create-category-modal")
|
||||||
|
.getByRole("button", { name: "Create category" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page
|
||||||
|
.locator("#create-category-modal")
|
||||||
|
.getByRole("button", { name: "Create category" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully created category")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "All categories" })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update category`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Update" }).first().click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Name:").click();
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Name:").fill(genCategoryName());
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Weight:").click();
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Weight:").fill(genCategoryWeight());
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update category" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update category" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated category")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete category`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Delete" }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Delete category" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Delete category" }).click();
|
||||||
|
|
||||||
|
switch (permissionLevel) {
|
||||||
|
case 10:
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
break;
|
||||||
|
case 20:
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText("Successfully deleted category")
|
||||||
|
.or(page.getByText("Insufficient permissions"))
|
||||||
|
).toBeVisible();
|
||||||
|
break;
|
||||||
|
case 30:
|
||||||
|
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
import { randomBytes } from "node:crypto";
|
|
||||||
|
|
||||||
const username = randomBytes(8).toString("hex");
|
|
||||||
const collabUsername = randomBytes(8).toString("hex");
|
|
||||||
const collabUsername2 = randomBytes(8).toString("hex");
|
|
||||||
const collabUsername3 = randomBytes(8).toString("hex");
|
|
||||||
const collabUsername4 = randomBytes(8).toString("hex");
|
|
||||||
const password = "T3stuser??!!";
|
|
||||||
|
|
||||||
const permissionLevels = [10, 20, 30];
|
|
||||||
|
|
||||||
test.describe.serial("Collaborator tests", () => {
|
|
||||||
test("Setup", async ({ page }) => {
|
|
||||||
await page.goto("/register");
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername2);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername3);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername4);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Create website" }).click();
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Blog");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Create website" }).click();
|
|
||||||
await page.getByLabel("Type: BlogDocs").selectOption("Docs");
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Documentation");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create article" }).click();
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Article-10");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page.getByRole("button", { name: "Add collaborator" }).click();
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("button", { name: "Add collaborator" }).click();
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername2);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("button", { name: "Add collaborator" }).click();
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername3);
|
|
||||||
await page.getByRole("combobox").selectOption("30");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create category" }).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).fill("Category-10");
|
|
||||||
await page.getByLabel("Weight:").click();
|
|
||||||
await page.getByLabel("Weight:").fill("10");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page.getByRole("button", { name: "Add collaborator" }).click();
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Legal information" }).click();
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").fill("## Content");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const permissionLevel of permissionLevels) {
|
|
||||||
test(`Set collaborator permission level to ${permissionLevel}`, async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: collabUsername })
|
|
||||||
.getByRole("button")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.getByRole("combobox").selectOption(permissionLevel.toString());
|
|
||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: collabUsername })
|
|
||||||
.getByRole("button")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.getByRole("combobox").selectOption(permissionLevel.toString());
|
|
||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial(`Permission level: ${permissionLevel}`, () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Update website", async ({ page }) => {
|
|
||||||
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").first().click();
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated website")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Delete website", async ({ page }) => {
|
|
||||||
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").nth(1).click();
|
|
||||||
await page.getByRole("button", { name: "Delete website" }).click();
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
});
|
|
||||||
test("Update Global", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Update Header", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Update Home", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.locator("#home").getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated home")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Update Footer", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.locator("#footer").getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated footer")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Create article", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create article" }).click();
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill(`Article-${permissionLevel}`);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully created article")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Update article", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: `Article-${permissionLevel}` })
|
|
||||||
.getByRole("link")
|
|
||||||
.click();
|
|
||||||
await page.getByLabel("Description:").click();
|
|
||||||
await page.getByLabel("Description:").fill("Description");
|
|
||||||
await page.getByLabel("Author:").click();
|
|
||||||
await page.getByLabel("Author:").fill("Author");
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").fill("## Main content");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Delete article", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: `Article-${permissionLevel}` })
|
|
||||||
.getByRole("button")
|
|
||||||
.click();
|
|
||||||
await page.getByRole("button", { name: "Delete article" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
if ([20, 30].includes(permissionLevel)) {
|
|
||||||
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator("li").filter({ hasText: `Article-10` }).getByRole("button").click();
|
|
||||||
await page.getByRole("button", { name: "Delete article" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 20) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Add collaborator", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page.getByRole("button", { name: "Add collaborator" }).click();
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername4);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if ([10, 20].includes(permissionLevel)) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully added")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Update collaborator", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: collabUsername2 })
|
|
||||||
.getByRole("button")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.getByRole("combobox").selectOption("20");
|
|
||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
|
||||||
|
|
||||||
if ([10, 20].includes(permissionLevel)) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated")).toBeVisible();
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: collabUsername2 })
|
|
||||||
.getByRole("button")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.getByRole("combobox").selectOption("30");
|
|
||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Remove collaborator", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: collabUsername2 })
|
|
||||||
.getByRole("button")
|
|
||||||
.nth(1)
|
|
||||||
.click();
|
|
||||||
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
|
||||||
|
|
||||||
if ([10, 20].includes(permissionLevel)) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully removed")).toBeVisible();
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: collabUsername3 })
|
|
||||||
.getByRole("button")
|
|
||||||
.nth(1)
|
|
||||||
.click();
|
|
||||||
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Create/Update legal information", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Legal information" }).click();
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").fill("## Content");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 30) {
|
|
||||||
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").fill("## Content updated");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 30) {
|
|
||||||
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Delete legal information", async ({ page }) => {
|
|
||||||
await page
|
|
||||||
.getByRole("link", {
|
|
||||||
name: [10, 20].includes(permissionLevel) ? "Documentation" : "Blog"
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
await page.getByRole("link", { name: "Legal information" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete legal information" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 30) {
|
|
||||||
await expect(page.getByText("Successfully deleted legal")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Create category", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create category" }).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).fill(`Category-${permissionLevel}`);
|
|
||||||
await page.getByRole("spinbutton", { name: "Weight:" }).click();
|
|
||||||
await page.getByRole("spinbutton", { name: "Weight:" }).fill(permissionLevel.toString());
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully created category")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Update category", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: `Category-${permissionLevel}` })
|
|
||||||
.getByRole("button")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.getByRole("spinbutton", { name: "Weight:" }).click();
|
|
||||||
await page
|
|
||||||
.getByRole("spinbutton", { name: "Weight:" })
|
|
||||||
.fill((permissionLevel * 2).toString());
|
|
||||||
await page.getByRole("button", { name: "Update category" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully updated category")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Delete category", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: `Category-${permissionLevel}` })
|
|
||||||
.getByRole("button")
|
|
||||||
.nth(1)
|
|
||||||
.click();
|
|
||||||
await page.getByRole("button", { name: "Delete category" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
}
|
|
||||||
if ([20, 30].includes(permissionLevel)) {
|
|
||||||
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: "Category-10" })
|
|
||||||
.getByRole("button")
|
|
||||||
.nth(1)
|
|
||||||
.click();
|
|
||||||
await page.getByRole("button", { name: "Delete category" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 20) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("Publish website", async ({ page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Publish" }).click();
|
|
||||||
await page.getByRole("button", { name: "Publish" }).click();
|
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
|
||||||
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByText("Successfully published website")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Delete all accounts", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername2);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername3);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername4);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
209
web-app/tests/collaborators.spec.ts
Normal file
209
web-app/tests/collaborators.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { randomBytes, randomInt, type UUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
register,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite,
|
||||||
|
userCollab10,
|
||||||
|
userCollab20,
|
||||||
|
userCollab30
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
const genUsername = () => randomBytes(8).toString("hex") as UUID;
|
||||||
|
const pickPermissionLevel = () => permissionLevels[randomInt(permissionLevels.length)].toString();
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test(`Add collaborator`, async ({ page }) => {
|
||||||
|
const addUsername = genUsername();
|
||||||
|
|
||||||
|
await register(addUsername, page);
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Collaborators" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Add collaborator" }).click();
|
||||||
|
await page.locator("#add-collaborator-modal").getByLabel("Username:").click();
|
||||||
|
await page.locator("#add-collaborator-modal").getByLabel("Username:").fill(addUsername);
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByLabel("Permission level:")
|
||||||
|
.selectOption(pickPermissionLevel());
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByRole("button", { name: "Add collaborator" })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText("Successfully added collaborator")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "All collaborators" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Modify", () => {
|
||||||
|
let modifyUsername: UUID;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
modifyUsername = genUsername();
|
||||||
|
await register(modifyUsername, page);
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Collaborators" }).click();
|
||||||
|
await page.getByRole("button", { name: "Add collaborator" }).click();
|
||||||
|
await page.locator("#add-collaborator-modal").getByLabel("Username:").click();
|
||||||
|
await page.locator("#add-collaborator-modal").getByLabel("Username:").fill(modifyUsername);
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByLabel("Permission level:")
|
||||||
|
.selectOption(pickPermissionLevel());
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByRole("button", { name: "Add collaborator" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update collaborator`, async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: modifyUsername })
|
||||||
|
.getByRole("button", { name: "Update" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page
|
||||||
|
.locator(`#${modalName}`)
|
||||||
|
.getByLabel("Permission level:")
|
||||||
|
.selectOption(pickPermissionLevel());
|
||||||
|
await page.getByRole("button", { name: "Update collaborator" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated collaborator")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Remove collaborator`, async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: modifyUsername })
|
||||||
|
.getByRole("button", { name: "Remove" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
||||||
|
await expect(page.getByText("Successfully removed collaborator")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test(`Add collaborator`, async ({ page }) => {
|
||||||
|
const addUsername = genUsername();
|
||||||
|
|
||||||
|
await register(addUsername, page);
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Collaborators" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Add collaborator" }).click();
|
||||||
|
await page.locator("#add-collaborator-modal").getByLabel("Username:").click();
|
||||||
|
await page.locator("#add-collaborator-modal").getByLabel("Username:").fill(addUsername);
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByLabel("Permission level:")
|
||||||
|
.selectOption(pickPermissionLevel());
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByRole("button", { name: "Add collaborator" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByRole("button", { name: "Add collaborator" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText("Successfully added collaborator")
|
||||||
|
.or(page.getByText("Insufficient permissions"))
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update collaborator`, async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Collaborators" }).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasNotText: new RegExp(`${userCollab10}|${userCollab20}|${userCollab30}`) })
|
||||||
|
.getByRole("button", { name: "Update" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page
|
||||||
|
.locator(`#${modalName}`)
|
||||||
|
.getByLabel("Permission level:")
|
||||||
|
.selectOption(pickPermissionLevel());
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update collaborator" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update collaborator" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText("Successfully updated collaborator")
|
||||||
|
.or(page.getByText("Insufficient permissions"))
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Remove collaborator`, async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Collaborators" }).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasNotText: new RegExp(`${userCollab10}|${userCollab20}|${userCollab30}`) })
|
||||||
|
.getByRole("button", { name: "Remove" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Remove collaborator" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText("Successfully removed collaborator")
|
||||||
|
.or(page.getByText("Insufficient permissions"))
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
106
web-app/tests/dashboard.spec.ts
Normal file
106
web-app/tests/dashboard.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Create website`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create website" }).click();
|
||||||
|
await page.getByLabel("Type:").selectOption("Blog");
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").fill("Blog");
|
||||||
|
await page
|
||||||
|
.locator("#create-website-modal")
|
||||||
|
.getByRole("button", { name: "Create website" })
|
||||||
|
.click();
|
||||||
|
const successCreation = page.getByText("Successfully created website");
|
||||||
|
const limitExceeded = page.getByText("Limit of 3 websites exceeded");
|
||||||
|
await expect(successCreation.or(limitExceeded)).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "All websites" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Modify", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create website" }).click();
|
||||||
|
await page.getByLabel("Type:").selectOption("Blog");
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").fill("Blog");
|
||||||
|
await page
|
||||||
|
.locator("#create-website-modal")
|
||||||
|
.getByRole("button", { name: "Create website" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update website`, async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasNotText: collabTestingWebsite })
|
||||||
|
.getByRole("button", { name: "Update" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Title:").click();
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Title:").fill(`${"Blog"} updated`);
|
||||||
|
await page.getByRole("button", { name: "Update website" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated website")).toBeVisible();
|
||||||
|
});
|
||||||
|
test(`Delete website`, async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasNotText: collabTestingWebsite })
|
||||||
|
.getByRole("button", { name: "Delete" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "Delete website" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted website")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update website", async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("button", { name: "Update" })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update website" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update website" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated website")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete website", async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("button", { name: "Delete" })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Delete website" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Delete website" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
64
web-app/tests/global-setup.ts
Normal file
64
web-app/tests/global-setup.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
allUsers,
|
||||||
|
register,
|
||||||
|
authenticate,
|
||||||
|
userOwner,
|
||||||
|
collabTestingWebsite,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
userDummy
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
for (const username of allUsers) {
|
||||||
|
test(`Register user "${username}`, async ({ page }) => {
|
||||||
|
await register(username, page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Collaborator testing website", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create website", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Create website" }).click();
|
||||||
|
await page.getByLabel("Type:").selectOption("Docs");
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").fill(collabTestingWebsite);
|
||||||
|
await page
|
||||||
|
.locator("#create-website-modal")
|
||||||
|
.getByRole("button", { name: "Create website" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test(`Add collaborator "${collabUsers.get(permissionLevel)}"`, async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: collabTestingWebsite }).click();
|
||||||
|
await page.getByRole("link", { name: "Collaborators" }).click();
|
||||||
|
await page.getByRole("button", { name: "Add collaborator" }).click();
|
||||||
|
await page.getByLabel("Username:").click();
|
||||||
|
await page.getByLabel("Username:").fill(collabUsers.get(permissionLevel)!);
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByLabel("Permission level:")
|
||||||
|
.selectOption(permissionLevel.toString());
|
||||||
|
await page
|
||||||
|
.locator("#add-collaborator-modal")
|
||||||
|
.getByRole("button", { name: "Add collaborator" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dummy user website", async ({ page }) => {
|
||||||
|
await authenticate(userDummy, page);
|
||||||
|
await page.getByRole("button", { name: "Create website" }).click();
|
||||||
|
await page.getByLabel("Type:").selectOption("Blog");
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").click();
|
||||||
|
await page.locator("#create-website-modal").getByLabel("Title:").fill("Dummy");
|
||||||
|
await page
|
||||||
|
.locator("#create-website-modal")
|
||||||
|
.getByRole("button", { name: "Create website" })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
31
web-app/tests/global-teardown.ts
Normal file
31
web-app/tests/global-teardown.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { test } from "@playwright/test";
|
||||||
|
import { password, authenticate, userOwner } from "./shared";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* test("Delete all regular users", async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: "Manage" }).click();
|
||||||
|
|
||||||
|
await page.waitForSelector("tbody");
|
||||||
|
const userRows = await page.locator("tbody > tr").filter({ hasNotText: userOwner }).all();
|
||||||
|
|
||||||
|
for (const row of userRows) {
|
||||||
|
await row.getByRole("button", { name: "Manage" }).click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).locator('summary:has-text("Delete")').click();
|
||||||
|
await page.locator(`#${modalName}`).getByRole("button", { name: "Delete user" }).click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete admin account", async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: "Account" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete account" }).click();
|
||||||
|
await page.getByLabel("Password:").click();
|
||||||
|
await page.getByLabel("Password:").fill(password);
|
||||||
|
await page
|
||||||
|
.locator("#delete-account-modal")
|
||||||
|
.getByRole("button", { name: "Delete account" })
|
||||||
|
.click();
|
||||||
|
}); */
|
||||||
109
web-app/tests/legal-information.spec.ts
Normal file
109
web-app/tests/legal-information.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Create/update legal information`, async ({ page }) => {
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Main content:").fill("## Content");
|
||||||
|
await page.getByRole("button", { name: "Update legal information" }).click();
|
||||||
|
await expect(page.getByText("Successfully created/updated legal information")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete legal information`, async ({ page }) => {
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Main content:").fill("## Arbitrary content");
|
||||||
|
await page.getByRole("button", { name: "Update legal information" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete legal information" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted legal information")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test(`Create/update legal information`, async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Main content:").fill("## Random content");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update legal information" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update legal information" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(
|
||||||
|
page.getByText("Successfully created/updated legal information")
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete legal information`, async ({ page, browserName }) => {
|
||||||
|
test.skip(browserName === "firefox", "Some issues with Firefox in headful mode");
|
||||||
|
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Main content:").fill("## Even more content");
|
||||||
|
await page.getByRole("button", { name: "Update legal information" }).click();
|
||||||
|
await page.waitForResponse(/createUpdateLegalInformation/);
|
||||||
|
await page.getByRole("link", { name: "Account" }).click();
|
||||||
|
await page.getByRole("button", { name: "Logout" }).click();
|
||||||
|
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Delete legal information" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Delete legal information" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully deleted legal information")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
67
web-app/tests/manage.spec.ts
Normal file
67
web-app/tests/manage.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { userOwner, userDummy, register, authenticate } from "./shared";
|
||||||
|
|
||||||
|
const userDeleted = "test-deleted-m";
|
||||||
|
|
||||||
|
test(`Update user website limit`, async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page.getByRole("link", { name: "Manage" }).click();
|
||||||
|
await page
|
||||||
|
.locator("tr")
|
||||||
|
.filter({ hasNotText: userOwner })
|
||||||
|
.getByRole("button", { name: "Manage" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Number of websites allowed:").click();
|
||||||
|
await page.locator(`#${modalName}`).getByLabel("Number of websites allowed:").fill("5");
|
||||||
|
await page.getByRole("button", { name: "Update website limit" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated user website limit")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Update user website storage limit`, async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page.getByRole("link", { name: "Manage" }).click();
|
||||||
|
await page
|
||||||
|
.locator("tr")
|
||||||
|
.filter({ hasText: userDummy })
|
||||||
|
.getByRole("button", { name: "Manage" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).locator("details > summary").first().click();
|
||||||
|
await page
|
||||||
|
.locator(`#${modalName}`)
|
||||||
|
.locator("details")
|
||||||
|
.getByLabel("Storage limit in MB:")
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.locator(`#${modalName}`)
|
||||||
|
.locator("details")
|
||||||
|
.getByLabel("Storage limit in MB:")
|
||||||
|
.first()
|
||||||
|
.fill("555");
|
||||||
|
await page
|
||||||
|
.locator(`#${modalName}`)
|
||||||
|
.locator("details")
|
||||||
|
.getByRole("button", { name: "Update storage limit" })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText("Successfully updated user website storage size")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete user`, async ({ page }) => {
|
||||||
|
await register(userDeleted, page);
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page.getByRole("link", { name: "Manage" }).click();
|
||||||
|
await page
|
||||||
|
.locator("tr")
|
||||||
|
.filter({ hasText: userDeleted })
|
||||||
|
.getByRole("button", { name: "Manage" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const modalName = page.url().split("#")[1];
|
||||||
|
await page.locator(`#${modalName}`).locator('summary:has-text("Delete")').click();
|
||||||
|
await page.locator(`#${modalName}`).getByRole("button", { name: "Delete user" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted user")).toBeVisible();
|
||||||
|
});
|
||||||
135
web-app/tests/publish.spec.ts
Normal file
135
web-app/tests/publish.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Publish" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Publish website`, async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "Publish" }).click();
|
||||||
|
await expect(page.getByText("Successfully published website")).toBeVisible();
|
||||||
|
await expect(page.getByText("Your website is published at")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Set custom domain prefix`, async ({ page }) => {
|
||||||
|
await page.getByLabel("Prefix:").click();
|
||||||
|
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Prefix:").fill("example-prefix");
|
||||||
|
await page.getByRole("button", { name: "Update domain prefix" }).click();
|
||||||
|
await expect(page.getByText("Successfully created/updated domain prefix")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete custom domain prefix`, async ({ page }) => {
|
||||||
|
await page.getByLabel("Prefix:").click();
|
||||||
|
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Prefix:").fill("example-prefix");
|
||||||
|
await page.getByRole("button", { name: "Update domain prefix" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete domain prefix" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted domain prefix")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test(`Publish website`, async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Publish" }).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Publish" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Publish" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully published website")).toBeVisible();
|
||||||
|
await expect(page.getByText("Your website is published at")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Set custom domain prefix`, async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Publish" }).click();
|
||||||
|
|
||||||
|
await page.getByLabel("Prefix:").click();
|
||||||
|
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Prefix:").fill("new-prefix");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update domain prefix" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update domain prefix" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully created/updated domain prefix")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete custom domain prefix`, async ({ page, browserName }) => {
|
||||||
|
test.skip(browserName === "firefox", "Some issues with Firefox in headful mode");
|
||||||
|
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Publish" }).click();
|
||||||
|
|
||||||
|
await page.getByLabel("Prefix:").click();
|
||||||
|
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Prefix:").fill("new-prefix");
|
||||||
|
await page.getByRole("button", { name: "Update domain prefix" }).click();
|
||||||
|
await page.waitForResponse(/createUpdateCustomDomainPrefix/);
|
||||||
|
await page.getByRole("link", { name: "Account" }).click();
|
||||||
|
await page.getByRole("button", { name: "Logout" }).click();
|
||||||
|
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Publish" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Delete domain prefix" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Delete domain prefix" }).click();
|
||||||
|
|
||||||
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully deleted domain prefix")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
175
web-app/tests/settings.spec.ts
Normal file
175
web-app/tests/settings.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import {
|
||||||
|
userOwner,
|
||||||
|
authenticate,
|
||||||
|
permissionLevels,
|
||||||
|
collabUsers,
|
||||||
|
collabTestingWebsite
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
const genRandomHex = () => `#${randomBytes(3).toString("hex")}`;
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
test.describe("Website owner", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(userOwner, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update global", async ({ page, browserName }) => {
|
||||||
|
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
|
||||||
|
|
||||||
|
await page.getByLabel("Background color dark theme:").click();
|
||||||
|
await page.getByLabel("Background color dark theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Background color light theme:").click();
|
||||||
|
await page.getByLabel("Background color light theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Accent color dark theme:").click();
|
||||||
|
await page.getByLabel("Accent color dark theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Accent color light theme:").click();
|
||||||
|
await page.getByLabel("Accent color light theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Favicon:").click();
|
||||||
|
await page
|
||||||
|
.getByLabel("Favicon:")
|
||||||
|
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
|
||||||
|
await page.getByRole("button", { name: "Update global" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update header", async ({ page, browserName }) => {
|
||||||
|
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
|
||||||
|
|
||||||
|
await page.getByLabel("Logo type:").selectOption("image");
|
||||||
|
await page.getByLabel("Logo text:").click();
|
||||||
|
await page.getByLabel("Logo text:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Logo text:").fill("Logo text");
|
||||||
|
await page.getByLabel("Logo image:").click();
|
||||||
|
await page
|
||||||
|
.getByLabel("Logo image")
|
||||||
|
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
|
||||||
|
await page.getByRole("button", { name: "Update header" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update home", async ({ page }) => {
|
||||||
|
await page.getByLabel("Description:").click();
|
||||||
|
await page.getByLabel("Description:").fill("Description comes here");
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Main content:").fill("## Updated main content");
|
||||||
|
await page.getByRole("button", { name: "Update home" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated home")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update footer", async ({ page }) => {
|
||||||
|
await page.getByLabel("Additional text:").click();
|
||||||
|
await page.getByLabel("Additional text:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Additional text:").fill("Updated footer content");
|
||||||
|
await page.getByRole("button", { name: "Update footer" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated footer")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permissionLevel of permissionLevels) {
|
||||||
|
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(collabUsers.get(permissionLevel)!, page);
|
||||||
|
await page
|
||||||
|
.locator("li")
|
||||||
|
.filter({ hasText: collabTestingWebsite })
|
||||||
|
.getByRole("link", { name: collabTestingWebsite })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update global", async ({ page, browserName }) => {
|
||||||
|
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
|
||||||
|
|
||||||
|
await page.getByLabel("Background color dark theme:").click();
|
||||||
|
await page.getByLabel("Background color dark theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Background color light theme:").click();
|
||||||
|
await page.getByLabel("Background color light theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Accent color dark theme:").click();
|
||||||
|
await page.getByLabel("Accent color dark theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Accent color light theme:").click();
|
||||||
|
await page.getByLabel("Accent color light theme:").fill(genRandomHex());
|
||||||
|
await page.getByLabel("Favicon:").click();
|
||||||
|
await page
|
||||||
|
.getByLabel("Favicon:")
|
||||||
|
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update global" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update global" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update header", async ({ page, browserName }) => {
|
||||||
|
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
|
||||||
|
|
||||||
|
await page.getByLabel("Logo type:").selectOption("image");
|
||||||
|
await page.getByLabel("Logo text:").click();
|
||||||
|
await page.getByLabel("Logo text:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Logo text:").fill("Logo text");
|
||||||
|
await page.getByLabel("Logo image:").click();
|
||||||
|
await page
|
||||||
|
.getByLabel("Logo image")
|
||||||
|
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update header" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update header" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update home", async ({ page }) => {
|
||||||
|
await page.getByLabel("Description:").click();
|
||||||
|
await page.getByLabel("Description:").fill("Description comes here");
|
||||||
|
await page.getByLabel("Main content:").click();
|
||||||
|
await page.getByLabel("Main content:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Main content:").fill("## Updated main content");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update home" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update home" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated home")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update footer", async ({ page }) => {
|
||||||
|
await page.getByLabel("Additional text:").click();
|
||||||
|
await page.getByLabel("Additional text:").press("ControlOrMeta+a");
|
||||||
|
await page.getByLabel("Additional text:").fill("Updated footer content");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Update footer" })
|
||||||
|
.evaluate((node) => node.removeAttribute("disabled"));
|
||||||
|
await page.getByRole("button", { name: "Update footer" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 10) {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Successfully updated footer")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
35
web-app/tests/shared.ts
Normal file
35
web-app/tests/shared.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export const userOwner = "test-owner";
|
||||||
|
export const userCollab10 = "test-collab10";
|
||||||
|
export const userCollab20 = "test-collab20";
|
||||||
|
export const userCollab30 = "test-collab30";
|
||||||
|
export const userDummy = "test-dummy";
|
||||||
|
|
||||||
|
export const collabUsers = new Map([
|
||||||
|
[10, userCollab10],
|
||||||
|
[20, userCollab20],
|
||||||
|
[30, userCollab30]
|
||||||
|
]);
|
||||||
|
export const permissionLevels = [10, 20, 30];
|
||||||
|
export const allUsers = [userOwner, userCollab10, userCollab20, userCollab30, userDummy];
|
||||||
|
export const password = "T3stinguser?!";
|
||||||
|
export const contentTypes = ["Blog", "Docs"];
|
||||||
|
export const collabTestingWebsite = "Collaborator testing";
|
||||||
|
|
||||||
|
export const register = async (username: string, page: Page) => {
|
||||||
|
await page.goto("/register");
|
||||||
|
await page.getByLabel("Username:").click();
|
||||||
|
await page.getByLabel("Username:").fill(username);
|
||||||
|
await page.getByLabel("Password:").click();
|
||||||
|
await page.getByLabel("Password:").fill(password);
|
||||||
|
await page.getByRole("button", { name: "Register" }).click();
|
||||||
|
};
|
||||||
|
export const authenticate = async (username: string, page: Page) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Username:").click();
|
||||||
|
await page.getByLabel("Username:").fill(username);
|
||||||
|
await page.getByLabel("Password:").click();
|
||||||
|
await page.getByLabel("Password:").fill(password);
|
||||||
|
await page.getByRole("button", { name: "Login" }).click();
|
||||||
|
};
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { platform } from "node:os";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const username = randomBytes(8).toString("hex");
|
|
||||||
const collabUsername = randomBytes(8).toString("hex");
|
|
||||||
const password = "T3stuser??!!";
|
|
||||||
|
|
||||||
const test = base.extend<{ authenticatedPage: Page }>({
|
|
||||||
authenticatedPage: async ({ page }, use) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await use(page);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial("Website tests", () => {
|
|
||||||
test("Register", async ({ page }) => {
|
|
||||||
await page.goto("/register");
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(username);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Create websites", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("button", { name: "Create website" }).click();
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Blog");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByRole("link", { name: "All websites" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Search & Filter")).toBeVisible();
|
|
||||||
await expect(page.getByText("Blog Type: Blog Created at:")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Create website" }).click();
|
|
||||||
await page.getByLabel("Type: BlogDocs").selectOption("Docs");
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Documentation");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByRole("link", { name: "All websites" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Search & Filter")).toBeVisible();
|
|
||||||
await expect(page.getByText("Documentation Type: Docs")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Update websites", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").first().click();
|
|
||||||
await page.getByRole("textbox", { name: "Title" }).click();
|
|
||||||
await page.getByRole("textbox", { name: "Title" }).fill("Blog updated");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByRole("link", { name: "Blog updated" })).toBeVisible();
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator("li")
|
|
||||||
.filter({ hasText: "Documentation" })
|
|
||||||
.getByRole("button")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.getByRole("textbox", { name: "Title" }).click();
|
|
||||||
await page.getByRole("textbox", { name: "Title" }).fill("Documentation updated");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByRole("link", { name: "Documentation updated" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial("Blog", () => {
|
|
||||||
test.describe.serial("Update settings", () => {
|
|
||||||
test("Global", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByLabel("Background color dark theme: ").click();
|
|
||||||
await page.getByLabel("Background color dark theme:").fill("#3975a2");
|
|
||||||
await page.getByLabel("Background color light theme:").click();
|
|
||||||
await page.getByLabel("Background color light theme:").fill("#41473e");
|
|
||||||
await page.getByLabel("Accent color dark theme: ").click();
|
|
||||||
await page.getByLabel("Accent color dark theme:").fill("#3975a2");
|
|
||||||
await page.getByLabel("Accent color light theme:").click();
|
|
||||||
await page.getByLabel("Accent color light theme:").fill("#41473e");
|
|
||||||
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
|
||||||
await page.getByLabel("Favicon:").click();
|
|
||||||
await page
|
|
||||||
.getByLabel("Favicon:")
|
|
||||||
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
|
|
||||||
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Header", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByLabel("Logo text:").click();
|
|
||||||
await page.getByLabel("Logo text:").fill("archtika Blog updated");
|
|
||||||
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
|
||||||
await page.getByLabel("Logo type: TextImage").selectOption("image");
|
|
||||||
await page.getByLabel("Logo image:").click();
|
|
||||||
await page
|
|
||||||
.getByLabel("Logo image:")
|
|
||||||
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
|
|
||||||
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Home", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").press("Control+a");
|
|
||||||
await page.getByLabel("Main content:").fill("## Some new content comes here");
|
|
||||||
await expect(page.getByRole("link", { name: "Some new content comes here" })).toBeVisible();
|
|
||||||
await page.locator("#home").getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated home")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Footer", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByLabel("Additional text:").click();
|
|
||||||
await page
|
|
||||||
.getByLabel("Additional text:")
|
|
||||||
.fill(
|
|
||||||
"archtika is a free, open, modern, performant and lightweight CMS updated content comes here"
|
|
||||||
);
|
|
||||||
await page.locator("#footer").getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated footer")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial("Articles", () => {
|
|
||||||
test("Create article", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create article" }).click();
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Test article");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByRole("link", { name: "All articles" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Search & Filter")).toBeVisible();
|
|
||||||
await expect(page.getByText("Test article Edit Delete")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Update article", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("link", { name: "Edit" }).click();
|
|
||||||
await page.getByLabel("Description:").click();
|
|
||||||
await page.getByLabel("Description:").fill("Sample article description");
|
|
||||||
await page.getByLabel("Author:").click();
|
|
||||||
await page.getByLabel("Author:").fill("John Doe");
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page
|
|
||||||
.getByLabel("Main content:")
|
|
||||||
.fill(
|
|
||||||
"## Section\n\n### Subsection\n\n## Second section\n\n### Second subsection\n\n#### Sub Sub section"
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.getByText(
|
|
||||||
"Table of contents SectionSubsectionSecond sectionSecond subsectionSub Sub"
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "Section", exact: true }).getByRole("link")
|
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Paste image", async ({ authenticatedPage: page, context }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("link", { name: "Edit" }).click();
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
|
|
||||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
||||||
const isMac = platform() === "darwin";
|
|
||||||
const modifier = isMac ? "Meta" : "Control";
|
|
||||||
|
|
||||||
const clipPage = await context.newPage();
|
|
||||||
await clipPage.goto("https://picsum.photos/400/400.jpg");
|
|
||||||
await clipPage.keyboard.press(`${modifier}+KeyC`);
|
|
||||||
|
|
||||||
await page.bringToFront();
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await page.keyboard.press(`${modifier}+KeyV`);
|
|
||||||
|
|
||||||
await expect(page.getByText("Successfully uploaded image")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Delete article", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete article" }).click();
|
|
||||||
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial("Collaborators", () => {
|
|
||||||
test("Add collaborator", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page.getByRole("button", { name: "Add collaborator" }).click();
|
|
||||||
await page.getByLabel("Username:").click();
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully added")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Update collaborator", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page.getByRole("button", { name: "Update" }).click();
|
|
||||||
await page.getByRole("combobox").selectOption("20");
|
|
||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Remove collaborator", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Collaborators" }).click();
|
|
||||||
await page.getByRole("button", { name: "Remove" }).click();
|
|
||||||
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
|
||||||
await expect(page.getByText("Successfully removed")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial("Legal information", () => {
|
|
||||||
test("Create/Update legal information", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Legal information" }).click();
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").fill("## Content");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page.getByLabel("Main content:").fill("## Content updated");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
|
|
||||||
});
|
|
||||||
test("Delete legal information", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Legal information" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete legal information" }).click();
|
|
||||||
await expect(page.getByText("Successfully deleted legal")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.serial("Docs", () => {
|
|
||||||
test.describe.serial("Categories", () => {
|
|
||||||
test("Create category", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create category" }).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).fill("Category");
|
|
||||||
await page.getByLabel("Weight:").click();
|
|
||||||
await page.getByLabel("Weight:").fill("1000");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully created category")).toBeVisible();
|
|
||||||
await expect(page.getByRole("link", { name: "All categories" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Category (1000)")).toBeVisible();
|
|
||||||
});
|
|
||||||
test("Update category", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page.getByRole("button", { name: "Update" }).click();
|
|
||||||
await page.getByRole("spinbutton", { name: "Weight:" }).click();
|
|
||||||
await page.getByRole("spinbutton", { name: "Weight:" }).fill("500");
|
|
||||||
await page.getByRole("button", { name: "Update category" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated category")).toBeVisible();
|
|
||||||
await expect(page.getByText("Category (500)")).toBeVisible();
|
|
||||||
});
|
|
||||||
test("Delete category", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete category" }).click();
|
|
||||||
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
|
||||||
await expect(page.getByRole("link", { name: "All categories" })).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Article", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Categories" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create category" }).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).click();
|
|
||||||
await page.getByLabel("Name:").nth(0).fill("Category");
|
|
||||||
await page.getByLabel("Weight:").click();
|
|
||||||
await page.getByLabel("Weight:").fill("1000");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Articles" }).click();
|
|
||||||
await page.getByRole("button", { name: "Create article" }).click();
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Article");
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Edit" }).click();
|
|
||||||
await page.getByLabel("Weight:").click();
|
|
||||||
await page.getByLabel("Weight:").fill("1000");
|
|
||||||
await page.getByLabel("Title:").click();
|
|
||||||
await page.getByLabel("Title:").fill("Article");
|
|
||||||
await page.getByLabel("Description:").click();
|
|
||||||
await page.getByLabel("Description:").fill("Testing out this article");
|
|
||||||
await page.getByLabel("Author:").click();
|
|
||||||
await page.getByLabel("Author:").fill("John Doe");
|
|
||||||
await page.getByLabel("Main content:").click();
|
|
||||||
await page
|
|
||||||
.getByLabel("Main content:")
|
|
||||||
.fill(
|
|
||||||
"## Main content comes in here\n\n### First section\n\n### Second section\n\n## More"
|
|
||||||
);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
|
||||||
await expect(page.getByText("Table of contents Main")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "Main content comes in here" }).getByRole("link")
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Publish websites", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
|
||||||
await page.getByRole("link", { name: "Publish" }).click();
|
|
||||||
await page.getByRole("button", { name: "Publish" }).click();
|
|
||||||
await expect(page.getByText("Successfully published website")).toBeVisible();
|
|
||||||
await expect(page.getByText("Your website is published at")).toBeVisible();
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("link", { name: "Documentation" }).click();
|
|
||||||
await page.getByRole("link", { name: "Publish" }).click();
|
|
||||||
await page.getByRole("button", { name: "Publish" }).click();
|
|
||||||
await expect(page.getByText("Successfully published website")).toBeVisible();
|
|
||||||
await expect(page.getByText("Your website is published at")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Delete websites", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("button", { name: "Delete" }).nth(1).click();
|
|
||||||
await page.getByRole("button", { name: "Delete website" }).click();
|
|
||||||
await expect(page.getByText("Successfully deleted website")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Delete" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete website" }).click();
|
|
||||||
await expect(page.getByText("Successfully deleted website")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "All websites" })).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Delete accounts", async ({ authenticatedPage: page }) => {
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
|
||||||
await page.getByRole("link", { name: "Account" }).click();
|
|
||||||
await page.getByRole("button", { name: "Delete account" }).click();
|
|
||||||
await page.getByLabel("Password:").click();
|
|
||||||
await page.getByLabel("Password:").fill(password);
|
|
||||||
await page
|
|
||||||
.locator("#delete-account-modal")
|
|
||||||
.getByRole("button", { name: "Delete account" })
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user