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,7 +101,25 @@ 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
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) INSERT INTO internal.website (content_type, title)
VALUES (create_website.content_type, create_website.title) VALUES (create_website.content_type, create_website.title)
RETURNING RETURNING
@@ -108,7 +140,7 @@ $$
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>