Add administrator role plus manage dashboard and cleanup database migrations

This commit is contained in:
thiloho
2024-10-08 21:20:44 +02:00
parent c4f1bff2a9
commit 1b74e1e6fb
23 changed files with 625 additions and 87 deletions

View File

@@ -67,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

View File

@@ -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;
@@ -25,7 +31,8 @@ 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),
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,6 +41,7 @@ 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(),
@@ -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,
@@ -103,7 +111,7 @@ CREATE TABLE internal.article (
publication_date DATE, publication_date DATE,
main_content VARCHAR(200000) 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,
@@ -128,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,
@@ -166,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;

View File

@@ -15,5 +15,5 @@ CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
-- migrate:down -- migrate:down
DROP EVENT TRIGGER pgrst_watch; DROP EVENT TRIGGER pgrst_watch;
DROP FUNCTION internal.pgrst_watch (); DROP FUNCTION internal.pgrst_watch;

View File

@@ -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
u.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 := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400; _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;
@@ -154,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;

View File

@@ -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;
@@ -24,7 +26,19 @@ CREATE VIEW api.website WITH ( security_invoker = ON
SELECT SELECT
* *
FROM FROM
internal.website; internal.website AS w
WHERE
w.user_id = (
CURRENT_SETTING(
'request.jwt.claims', TRUE
)::JSON ->> 'user_id')::UUID
OR w.id IN (
SELECT
c.website_id
FROM
internal.collab AS c
WHERE
c.user_id = (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID);
CREATE VIEW api.settings WITH ( security_invoker = ON CREATE VIEW api.settings WITH ( security_invoker = ON
) AS ) AS
@@ -87,28 +101,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. 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.'); 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) INSERT INTO internal.footer (website_id, additional_text)
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS'); VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
website_id := _website_id; 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;
@@ -154,7 +186,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;

View File

@@ -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, 30)); 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -9,7 +9,7 @@ DECLARE
_mimetype TEXT := _headers ->> 'x-mimetype'; _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;
BEGIN BEGIN
_has_access = internal.user_has_website_access (_website_id, 20); _has_access = internal.user_has_website_access (_website_id, 20);
@@ -24,7 +24,7 @@ BEGIN
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp, avif, gif, svg'; 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)
@@ -70,16 +70,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 "*/*";

View File

@@ -81,71 +81,71 @@ $$
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER; SECURITY DEFINER;
CREATE TRIGGER website_track_changes CREATE TRIGGER track_changes_website
AFTER UPDATE ON internal.website AFTER UPDATE ON internal.website
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER settings_track_changes CREATE TRIGGER track_changes_settings
AFTER UPDATE ON internal.settings AFTER UPDATE ON internal.settings
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER header_track_changes CREATE TRIGGER track_changes_header
AFTER UPDATE ON internal.header AFTER UPDATE ON internal.header
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER home_track_changes CREATE TRIGGER track_changes_home
AFTER UPDATE ON internal.home AFTER UPDATE ON internal.home
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER article_track_changes CREATE TRIGGER track_changes_article
AFTER INSERT OR UPDATE OR DELETE ON internal.article AFTER INSERT OR UPDATE OR DELETE ON internal.article
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER docs_category_track_changes CREATE TRIGGER track_changes_docs_category
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER footer_track_changes CREATE TRIGGER track_changes_footer
AFTER UPDATE ON internal.footer AFTER UPDATE ON internal.footer
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER legal_information_track_changes CREATE TRIGGER track_changes_legal_information
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER collab_track_changes CREATE TRIGGER track_changes_collab
AFTER INSERT OR UPDATE OR DELETE ON internal.collab AFTER INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); 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_settings ON internal.settings;
DROP TRIGGER header_track_changes ON internal.header; DROP TRIGGER track_changes_header ON internal.header;
DROP TRIGGER home_track_changes ON internal.home; DROP TRIGGER track_changes_home ON internal.home;
DROP TRIGGER article_track_changes ON internal.article; DROP TRIGGER track_changes_article ON internal.article;
DROP TRIGGER docs_category_track_changes ON internal.docs_category; DROP TRIGGER track_changes_docs_category ON internal.docs_category;
DROP TRIGGER footer_track_changes ON internal.footer; DROP TRIGGER track_changes_footer ON internal.footer;
DROP TRIGGER legal_information_track_changes ON internal.legal_information; DROP TRIGGER track_changes_legal_information ON internal.legal_information;
DROP TRIGGER collab_track_changes ON internal.collab; DROP TRIGGER track_changes_collab ON internal.collab;
DROP FUNCTION internal.track_changes (); DROP FUNCTION internal.track_changes;
DROP VIEW api.change_log; DROP VIEW api.change_log;

View File

@@ -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;

View File

@@ -0,0 +1,203 @@
-- 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 ();
CREATE VIEW api.all_user_websites AS
SELECT
u.id AS user_id,
u.username,
u.created_at AS user_created_at,
u.max_number_websites,
COALESCE(JSONB_AGG(JSONB_BUILD_OBJECT('id', w.id, 'title', w.title, 'max_storage_size', w.max_storage_size)
ORDER BY w.created_at DESC) FILTER (WHERE w.id IS NOT NULL), '[]'::JSONB) AS websites
FROM
internal.user AS u
LEFT JOIN internal.website AS w ON u.id = w.user_id
GROUP BY
u.id;
GRANT SELECT ON api.all_user_websites TO administrator;
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;
DROP VIEW api.all_user_websites;
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;

View File

@@ -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;
} }
} }

View File

@@ -46,12 +46,16 @@
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 newContent = const markdownToInsert = `![](${fileUrl})`;
target.value.slice(0, target.selectionStart) + const cursorPosition = target.selectionStart;
`![](${fileUrl})` + const newContent = target.value.slice(0, target.selectionStart) + markdownToInsert;
target.value.slice(target.selectionStart); target.value.slice(target.selectionStart);
previewContent.value = newContent; previewContent.value = newContent;
const newCursorPosition = cursorPosition + markdownToInsert.length;
target.setSelectionRange(newCursorPosition, newCursorPosition);
target.focus();
} else { } else {
return; return;
} }

View File

@@ -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;

View File

@@ -438,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: {},
@@ -464,6 +466,7 @@ 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;
@@ -474,6 +477,7 @@ export interface WebsiteInput {
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;
@@ -486,6 +490,7 @@ 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",

View File

@@ -151,8 +151,8 @@ export const md = (markdownContent: string, showToc = true) => {
try { try {
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false })); html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
} catch (_) { } catch (error) {
html = "Failed to parse markdown"; html = JSON.stringify(error);
} }
return html; return html;

View File

@@ -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
}; };
}; };

View File

@@ -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 &mdash; {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,22 @@
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);
}
meter {
inline-size: min(512px, 100%);
block-size: 2rem;
}
</style> </style>

View File

@@ -0,0 +1,68 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async ({ fetch }) => {
const allUsers = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/all_user_websites?order=user_created_at.desc`,
"GET",
{
returnData: true
}
)
).data;
return {
allUsers,
API_BASE_PREFIX
};
};
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();
console.log(`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`);
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"
}
);
}
};

View File

@@ -0,0 +1,126 @@
<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";
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">
<h2>
<a href="#all-users">All users</a>
</h2>
<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.allUsers as { user_id, user_created_at, username, max_number_websites, websites }}
<tr>
<td>
<DateTime date={user_created_at} />
</td>
<td>{user_id}</td>
<td>{username}</td>
<td>
<Modal id="manage-user-{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={user_id} />
<label>
Number of websites allowed:
<input
type="number"
name="number-of-websites"
min="0"
value={max_number_websites}
/>
</label>
<button type="submit">Submit</button>
</form>
{#if websites.length > 0}
<h4>Websites</h4>
{#each websites 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">Submit</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={user_id} />
<button type="submit">Delete user</button>
</form>
</div>
</details>
</Modal>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<style>
form[action="?/deleteUser"] {
margin-block-start: var(--space-2xs);
}
</style>

View File

@@ -46,8 +46,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>

View File

@@ -53,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>