mirror of
https://github.com/thiloho/archtika.git
synced 2025-11-22 02:41:35 +01:00
Add administrator role plus manage dashboard and cleanup database migrations
This commit is contained in:
@@ -67,8 +67,12 @@
|
||||
type = "app";
|
||||
program = "${pkgs.writeShellScriptBin "api-setup" ''
|
||||
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.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
|
||||
|
||||
|
||||
@@ -9,10 +9,16 @@ CREATE ROLE anon NOLOGIN NOINHERIT;
|
||||
|
||||
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
||||
|
||||
CREATE ROLE administrator NOLOGIN;
|
||||
|
||||
GRANT anon 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 authenticated_user;
|
||||
@@ -25,7 +31,8 @@ CREATE TABLE internal.user (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
|
||||
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()
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
|
||||
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,
|
||||
created_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,
|
||||
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_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(),
|
||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||
@@ -103,7 +111,7 @@ CREATE TABLE internal.article (
|
||||
publication_date DATE,
|
||||
main_content VARCHAR(200000) CHECK (TRIM(main_content) != ''),
|
||||
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(),
|
||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||
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 (
|
||||
website_id UUID REFERENCES internal.website (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(),
|
||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||
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 administrator;
|
||||
|
||||
DROP ROLE authenticator;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
|
||||
|
||||
@@ -15,5 +15,5 @@ CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
|
||||
-- migrate:down
|
||||
DROP EVENT TRIGGER pgrst_watch;
|
||||
|
||||
DROP FUNCTION internal.pgrst_watch ();
|
||||
DROP FUNCTION internal.pgrst_watch;
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ BEGIN
|
||||
FROM
|
||||
pg_roles AS r
|
||||
WHERE
|
||||
r.rolname = NEW.role)) THEN
|
||||
r.rolname = NEW.user_role)) THEN
|
||||
RAISE foreign_key_violation
|
||||
USING message = 'Unknown database role: ' || NEW.role;
|
||||
USING message = 'Unknown database role: ' || NEW.user_role;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END
|
||||
@@ -48,7 +48,7 @@ CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME
|
||||
AS $$
|
||||
BEGIN
|
||||
SELECT
|
||||
u.role INTO role_name
|
||||
u.user_role INTO role_name
|
||||
FROM
|
||||
internal.user AS u
|
||||
WHERE
|
||||
@@ -96,8 +96,17 @@ BEGIN
|
||||
RAISE invalid_parameter_value
|
||||
USING message = 'Password must contain at least one special character';
|
||||
ELSE
|
||||
INSERT INTO internal.user (username, password_hash)
|
||||
VALUES (register.username, register.pass)
|
||||
INSERT INTO internal.user (username, password_hash, user_role)
|
||||
SELECT
|
||||
register.username,
|
||||
register.pass,
|
||||
CASE WHEN COUNT(*) = 0 THEN
|
||||
'administrator'
|
||||
ELSE
|
||||
'authenticated_user'
|
||||
END
|
||||
FROM
|
||||
internal.user
|
||||
RETURNING
|
||||
id INTO user_id;
|
||||
END IF;
|
||||
@@ -111,7 +120,7 @@ AS $$
|
||||
DECLARE
|
||||
_role NAME;
|
||||
_user_id UUID;
|
||||
_exp INTEGER := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
|
||||
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400;
|
||||
BEGIN
|
||||
SELECT
|
||||
internal.user_role (login.username, login.pass) INTO _role;
|
||||
@@ -154,28 +163,28 @@ $$
|
||||
LANGUAGE plpgsql
|
||||
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
|
||||
DROP TRIGGER encrypt_pass 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;
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ CREATE VIEW api.user WITH ( security_invoker = ON
|
||||
) AS
|
||||
SELECT
|
||||
id,
|
||||
username
|
||||
username,
|
||||
created_at,
|
||||
max_number_websites
|
||||
FROM
|
||||
internal.user;
|
||||
|
||||
@@ -24,7 +26,19 @@ CREATE VIEW api.website WITH ( security_invoker = ON
|
||||
SELECT
|
||||
*
|
||||
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
|
||||
) AS
|
||||
@@ -87,7 +101,25 @@ AS $$
|
||||
DECLARE
|
||||
_website_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
|
||||
IF (_user_website_count + 1 > _user_max_websites_allowed_count) THEN
|
||||
RAISE invalid_parameter_value
|
||||
USING message = FORMAT('Limit of %s websites exceeded', _user_max_websites_allowed_count);
|
||||
END IF;
|
||||
INSERT INTO internal.website (content_type, title)
|
||||
VALUES (create_website.content_type, create_website.title)
|
||||
RETURNING
|
||||
@@ -108,7 +140,7 @@ $$
|
||||
LANGUAGE plpgsql
|
||||
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
|
||||
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;
|
||||
|
||||
-- migrate:down
|
||||
DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
|
||||
DROP FUNCTION api.create_website;
|
||||
|
||||
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;
|
||||
|
||||
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 $$
|
||||
DECLARE
|
||||
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||
@@ -63,19 +63,29 @@ $$
|
||||
LANGUAGE plpgsql
|
||||
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
|
||||
FOR SELECT
|
||||
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
|
||||
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
|
||||
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
|
||||
FOR DELETE
|
||||
@@ -180,6 +190,10 @@ CREATE POLICY delete_collaborations ON internal.collab
|
||||
-- migrate:down
|
||||
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 delete_website ON internal.website;
|
||||
@@ -234,7 +248,7 @@ DROP POLICY update_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;
|
||||
|
||||
|
||||
@@ -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 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
|
||||
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;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ DECLARE
|
||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
||||
_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;
|
||||
BEGIN
|
||||
_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';
|
||||
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
|
||||
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
|
||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
||||
@@ -70,16 +70,16 @@ $$
|
||||
LANGUAGE plpgsql
|
||||
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
|
||||
DROP FUNCTION api.upload_file (BYTEA);
|
||||
DROP FUNCTION api.upload_file;
|
||||
|
||||
DROP FUNCTION api.retrieve_file (UUID);
|
||||
DROP FUNCTION api.retrieve_file;
|
||||
|
||||
DROP DOMAIN "*/*";
|
||||
|
||||
|
||||
@@ -81,71 +81,71 @@ $$
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER website_track_changes
|
||||
CREATE TRIGGER track_changes_website
|
||||
AFTER UPDATE ON internal.website
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION internal.track_changes ();
|
||||
|
||||
CREATE TRIGGER settings_track_changes
|
||||
CREATE TRIGGER track_changes_settings
|
||||
AFTER UPDATE ON internal.settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION internal.track_changes ();
|
||||
|
||||
CREATE TRIGGER header_track_changes
|
||||
CREATE TRIGGER track_changes_header
|
||||
AFTER UPDATE ON internal.header
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION internal.track_changes ();
|
||||
|
||||
CREATE TRIGGER home_track_changes
|
||||
CREATE TRIGGER track_changes_home
|
||||
AFTER UPDATE ON internal.home
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION internal.track_changes ();
|
||||
|
||||
CREATE TRIGGER article_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 docs_category_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 footer_track_changes
|
||||
CREATE TRIGGER track_changes_footer
|
||||
AFTER UPDATE ON internal.footer
|
||||
FOR EACH ROW
|
||||
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
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION internal.track_changes ();
|
||||
|
||||
CREATE TRIGGER collab_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
|
||||
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;
|
||||
|
||||
|
||||
@@ -41,13 +41,13 @@ CREATE TRIGGER update_domain_prefix_last_modified
|
||||
FOR EACH ROW
|
||||
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
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION internal.track_changes ();
|
||||
|
||||
-- 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;
|
||||
|
||||
|
||||
203
rest-api/db/migrations/20241006165029_administrator.sql
Normal file
203
rest-api/db/migrations/20241006165029_administrator.sql
Normal 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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||
import type { User } from "$lib/db-schema";
|
||||
|
||||
export const handle = async ({ event, resolve }) => {
|
||||
if (!event.url.pathname.startsWith("/api/")) {
|
||||
@@ -20,6 +21,13 @@ export const handle = async ({ event, resolve }) => {
|
||||
throw redirect(303, "/");
|
||||
}
|
||||
|
||||
if (
|
||||
(userData.data as User).user_role !== "administrator" &&
|
||||
event.url.pathname.includes("/manage")
|
||||
) {
|
||||
throw redirect(303, "/");
|
||||
}
|
||||
|
||||
event.locals.user = userData.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,16 @@
|
||||
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
|
||||
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
const newContent =
|
||||
target.value.slice(0, target.selectionStart) +
|
||||
`` +
|
||||
const markdownToInsert = ``;
|
||||
const cursorPosition = target.selectionStart;
|
||||
const newContent = target.value.slice(0, target.selectionStart) + markdownToInsert;
|
||||
target.value.slice(target.selectionStart);
|
||||
|
||||
previewContent.value = newContent;
|
||||
|
||||
const newCursorPosition = cursorPosition + markdownToInsert.length;
|
||||
target.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
target.focus();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
border: var(--border-primary);
|
||||
inline-size: var(--modal-width);
|
||||
max-inline-size: 100%;
|
||||
inline-size: min(var(--modal-width), 100%);
|
||||
max-block-size: calc(100vh - var(--space-m));
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
|
||||
@@ -438,19 +438,21 @@ export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
user_role: string;
|
||||
max_number_websites: number;
|
||||
created_at: Date;
|
||||
}
|
||||
export interface UserInput {
|
||||
id?: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role?: string;
|
||||
user_role?: string;
|
||||
max_number_websites?: number;
|
||||
created_at?: Date;
|
||||
}
|
||||
const 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"],
|
||||
primaryKey: "id",
|
||||
foreignKeys: {},
|
||||
@@ -464,6 +466,7 @@ export interface Website {
|
||||
user_id: string;
|
||||
content_type: string;
|
||||
title: string;
|
||||
max_storage_size: number;
|
||||
is_published: boolean;
|
||||
created_at: Date;
|
||||
last_modified_at: Date;
|
||||
@@ -474,6 +477,7 @@ export interface WebsiteInput {
|
||||
user_id?: string;
|
||||
content_type: string;
|
||||
title: string;
|
||||
max_storage_size?: number;
|
||||
is_published?: boolean;
|
||||
created_at?: Date;
|
||||
last_modified_at?: Date;
|
||||
@@ -486,6 +490,7 @@ const website = {
|
||||
"user_id",
|
||||
"content_type",
|
||||
"title",
|
||||
"max_storage_size",
|
||||
"is_published",
|
||||
"created_at",
|
||||
"last_modified_at",
|
||||
|
||||
@@ -151,8 +151,8 @@ export const md = (markdownContent: string, showToc = true) => {
|
||||
|
||||
try {
|
||||
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
|
||||
} catch (_) {
|
||||
html = "Failed to parse markdown";
|
||||
} catch (error) {
|
||||
html = JSON.stringify(error);
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
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 {
|
||||
user: locals.user
|
||||
user: locals.user,
|
||||
storageSizes
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,30 @@
|
||||
</ul>
|
||||
</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">
|
||||
<h2>
|
||||
<a href="#logout">Logout</a>
|
||||
@@ -71,4 +95,22 @@
|
||||
form[action="?/logout"] > button {
|
||||
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>
|
||||
|
||||
68
web-app/src/routes/(authenticated)/manage/+page.server.ts
Normal file
68
web-app/src/routes/(authenticated)/manage/+page.server.ts
Normal 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"
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
126
web-app/src/routes/(authenticated)/manage/+page.svelte
Normal file
126
web-app/src/routes/(authenticated)/manage/+page.svelte
Normal 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>
|
||||
@@ -46,8 +46,7 @@
|
||||
<a href="#publication-status">Publication status</a>
|
||||
</h2>
|
||||
<p>
|
||||
Your website is published at:
|
||||
<br />
|
||||
Your website is published at:<br />
|
||||
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
{/if}
|
||||
<ul class="link-wrapper unpadded">
|
||||
{#if data.user}
|
||||
{#if data.user.user_role === "administrator"}
|
||||
<li>
|
||||
<a href="/manage">Manage</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<a href="/account">Account</a>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user