diff --git a/migrations/0002_whole_vivisector.sql b/migrations/0002_whole_vivisector.sql new file mode 100644 index 0000000..7006cee --- /dev/null +++ b/migrations/0002_whole_vivisector.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user_token` MODIFY COLUMN `type` enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','invite','recovery') NOT NULL;--> statement-breakpoint +ALTER TABLE `privilege` ADD `automatic` tinyint DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `user_token` ADD `metadata` text; \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..eaafeee --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,1064 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "31269eb0-9aaf-417a-983c-68912644e6dc", + "prevId": "fa80e0d2-53bc-4557-a754-bc9fd9cad9aa", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_ua": { + "name": "actor_ua", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flagged": { + "name": "flagged", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "actorId": { + "name": "actorId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actorId_user_id_fk": { + "name": "audit_log_actorId_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "actorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "document": { + "name": "document", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "document_authorId_user_id_fk": { + "name": "document_authorId_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "o_auth2_client": { + "name": "o_auth2_client", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grants": { + "name": "grants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('authorization_code')" + }, + "activated": { + "name": "activated", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "verified": { + "name": "verified", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pictureId": { + "name": "pictureId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_pictureId_upload_id_fk": { + "name": "o_auth2_client_pictureId_upload_id_fk", + "tableFrom": "o_auth2_client", + "tableTo": "upload", + "columnsFrom": [ + "pictureId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "o_auth2_client_ownerId_user_id_fk": { + "name": "o_auth2_client_ownerId_user_id_fk", + "tableFrom": "o_auth2_client", + "tableTo": "user", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "IDX_e9d16c213910ad57bd05e97b42": { + "name": "IDX_e9d16c213910ad57bd05e97b42", + "columns": [ + "client_id" + ] + } + } + }, + "o_auth2_client_authorization": { + "name": "o_auth2_client_authorization", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "current": { + "name": "current", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_authorization_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_client_authorization_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_client_authorization", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_client_authorization_userId_user_id_fk": { + "name": "o_auth2_client_authorization_userId_user_id_fk", + "tableFrom": "o_auth2_client_authorization", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "o_auth2_client_manager": { + "name": "o_auth2_client_manager", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuerId": { + "name": "issuerId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_manager_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_client_manager_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_client_manager", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_client_manager_userId_user_id_fk": { + "name": "o_auth2_client_manager_userId_user_id_fk", + "tableFrom": "o_auth2_client_manager", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_client_manager_issuerId_user_id_fk": { + "name": "o_auth2_client_manager_issuerId_user_id_fk", + "tableFrom": "o_auth2_client_manager", + "tableTo": "user", + "columnsFrom": [ + "issuerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "o_auth2_client_url": { + "name": "o_auth2_client_url", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('redirect_uri','terms','privacy','website')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_client_url_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_client_url_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_client_url", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "o_auth2_token": { + "name": "o_auth2_token", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "enum('code','access_token','refresh_token')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp()" + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "pcke": { + "name": "pcke", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "o_auth2_token_userId_user_id_fk": { + "name": "o_auth2_token_userId_user_id_fk", + "tableFrom": "o_auth2_token", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "o_auth2_token_clientId_o_auth2_client_id_fk": { + "name": "o_auth2_token_clientId_o_auth2_client_id_fk", + "tableFrom": "o_auth2_token", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "privilege": { + "name": "privilege", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automatic": { + "name": "automatic", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "privilege_clientId_o_auth2_client_id_fk": { + "name": "privilege_clientId_o_auth2_client_id_fk", + "tableFrom": "privilege", + "tableTo": "o_auth2_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "upload": { + "name": "upload", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "original_name": { + "name": "original_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file": { + "name": "file", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaderId": { + "name": "uploaderId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "upload_uploaderId_user_id_fk": { + "name": "upload_uploaderId_user_id_fk", + "tableFrom": "upload", + "tableTo": "user", + "columnsFrom": [ + "uploaderId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "uuid": { + "name": "uuid", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated": { + "name": "activated", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activity_at": { + "name": "activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp()" + }, + "pictureId": { + "name": "pictureId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_pictureId_upload_id_fk": { + "name": "user_pictureId_upload_id_fk", + "tableFrom": "user", + "tableTo": "upload", + "columnsFrom": [ + "pictureId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "IDX_a95e949168be7b7ece1a2382fe": { + "name": "IDX_a95e949168be7b7ece1a2382fe", + "columns": [ + "uuid" + ] + }, + "IDX_78a916df40e02a9deb1c4b75ed": { + "name": "IDX_78a916df40e02a9deb1c4b75ed", + "columns": [ + "username" + ] + }, + "IDX_e12875dfb3b1d92d7d7c5377e2": { + "name": "IDX_e12875dfb3b1d92d7d7c5377e2", + "columns": [ + "email" + ] + } + } + }, + "user_privileges_privilege": { + "name": "user_privileges_privilege", + "columns": { + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "privilegeId": { + "name": "privilegeId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "IDX_0664a7ff494a1859a09014c0f1": { + "name": "IDX_0664a7ff494a1859a09014c0f1", + "columns": [ + "userId" + ], + "isUnique": false + }, + "IDX_e71171f4ed20bc8564a1819d0b": { + "name": "IDX_e71171f4ed20bc8564a1819d0b", + "columns": [ + "privilegeId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_privileges_privilege_userId_user_id_fk": { + "name": "user_privileges_privilege_userId_user_id_fk", + "tableFrom": "user_privileges_privilege", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_privileges_privilege_privilegeId_privilege_id_fk": { + "name": "user_privileges_privilege_privilegeId_privilege_id_fk", + "tableFrom": "user_privileges_privilege", + "tableTo": "privilege", + "columnsFrom": [ + "privilegeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_token": { + "name": "user_token", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','invite','recovery')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "current_timestamp(6)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_token_userId_user_id_fk": { + "name": "user_token_userId_user_id_fk", + "tableFrom": "user_token", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 0c5c85a..fefc4a9 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1717232859846, "tag": "0001_redundant_layla_miller", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1717344670405, + "tag": "0002_whole_vivisector", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/i18n/en/admin.json b/src/lib/i18n/en/admin.json index b918084..d5908ae 100644 --- a/src/lib/i18n/en/admin.json +++ b/src/lib/i18n/en/admin.json @@ -105,6 +105,12 @@ "account": "Change user account settings", "openid": "Get an ID token JWT (OpenID Connect)" }, + "managers": { + "title": "Application members", + "hint": "These users can edit this application just like you can, except that they cannot regenerate the secret or delete the application. Please note that they must have an active account on {{siteName}}, but not necessarily with the provided address.", + "add": "Invite a new member", + "invite": "Invite" + }, "errors": { "noRedirect": "At least one Redirect URI is required for you to be able to use this application!", "forbidden": "This action is forbidden for this user.", @@ -116,6 +122,8 @@ "invalidUrl": "Invalid URL provided.", "invalidPrivilegeId": "Invalid privilege ID for deletion.", "invalidPrivilege": "Invalid privilege provided.", + "invalidEmail": "Invalid email address.", + "emailExists": "This email address is already added.", "noFile": "Please upload a file first." } } diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index 562da61..5a702e3 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -5,4 +5,13 @@ export class ApiUtils { headers: { 'Content-Type': 'application/json' } }); } + + static redirect(url: string, status = 302): Response { + return new Response(null, { + status, + headers: { + Location: url + } + }); + } } diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts index 70bd309..6cd42ea 100644 --- a/src/lib/server/drizzle/schema.ts +++ b/src/lib/server/drizzle/schema.ts @@ -145,7 +145,8 @@ export type NewOAuth2Token = typeof oauth2Token.$inferInsert; export const privilege = mysqlTable('privilege', { id: int('id').autoincrement().notNull(), name: text('name').notNull(), - clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }) + clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }), + automatic: tinyint('automatic').default(0).notNull() }); export type Privilege = typeof privilege.$inferSelect; @@ -235,11 +236,13 @@ export const userToken = mysqlTable('user_token', { 'gdpr', 'totp', 'public_key', + 'invite', 'recovery' ]).notNull(), expires_at: timestamp('expires_at', { mode: 'date' }), userId: int('userId').references(() => user.id, { onDelete: 'cascade' }), nonce: text('nonce'), + metadata: text('metadata'), created_at: datetime('created_at', { mode: 'date', fsp: 6 }) .default(sql`current_timestamp(6)`) .notNull() diff --git a/src/lib/server/email/templates/index.ts b/src/lib/server/email/templates/index.ts index 33f22bd..1e513c5 100644 --- a/src/lib/server/email/templates/index.ts +++ b/src/lib/server/email/templates/index.ts @@ -1,3 +1,4 @@ export * from './forgot-password.email'; export * from './invitation.email'; +export * from './oauth2-invitation.email'; export * from './registration.email'; diff --git a/src/lib/server/email/templates/oauth2-invitation.email.ts b/src/lib/server/email/templates/oauth2-invitation.email.ts new file mode 100644 index 0000000..76339c7 --- /dev/null +++ b/src/lib/server/email/templates/oauth2-invitation.email.ts @@ -0,0 +1,31 @@ +import { PUBLIC_SITE_NAME } from '$env/static/public'; +import type { EmailTemplate } from '../template.interface'; + +export const OAuth2InvitationEmail = ( + inviter: string, + clientName: string, + url: string +): EmailTemplate => ({ + text: ` +${PUBLIC_SITE_NAME} + +${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}. + +Please click on the following link to accept the invitation. + +Accept invitation: ${url} + +This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email. + `, + html: /* html */ ` +

${PUBLIC_SITE_NAME}

+ +

${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}. + +

Please click on the following link to accept the invitation ${PUBLIC_SITE_NAME}.

+ +

Accept invitation: ${url}

+ +

This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.

+ ` +}); diff --git a/src/lib/server/oauth2/model/client.ts b/src/lib/server/oauth2/model/client.ts index 2683526..70c0776 100644 --- a/src/lib/server/oauth2/model/client.ts +++ b/src/lib/server/oauth2/model/client.ts @@ -1,3 +1,4 @@ +import { PUBLIC_URL, PUBLIC_SITE_NAME } from '$env/static/public'; import { CryptoUtils } from '$lib/server/crypto-utils'; import { db, @@ -12,7 +13,9 @@ import { type OAuth2ClientUrl, type User } from '$lib/server/drizzle'; +import { Emails, OAuth2InvitationEmail } from '$lib/server/email'; import { Uploads } from '$lib/server/upload'; +import { UserTokens } from '$lib/server/users'; import type { PaginationMeta } from '$lib/types'; import { and, count, eq, like, or, sql } from 'drizzle-orm'; @@ -38,6 +41,7 @@ export interface OAuth2AuthorizedUser { } export interface OAuth2ManagerUser { + id: number; email: string; } @@ -369,6 +373,57 @@ export class OAuth2Clients { await db.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id)); } + static async getManagers(client: OAuth2Client) { + return await db + .select({ id: oauth2ClientManager.id, email: user.email }) + .from(oauth2ClientManager) + .innerJoin(user, eq(user.id, oauth2ClientManager.userId)) + .where(eq(oauth2ClientManager.clientId, client.id)); + } + + static async addManager(client: OAuth2Client, actor: User, subject: User) { + await db.insert(oauth2ClientManager).values({ + clientId: client.id, + userId: subject.id, + issuerId: actor.id + }); + } + + static async removeManager(client: OAuth2Client, managerId: number) { + await db + .delete(oauth2ClientManager) + .where( + and(eq(oauth2ClientManager.clientId, client.id), eq(oauth2ClientManager.id, managerId)) + ); + } + + static async sendManagerInvitationEmail(client: OAuth2Client, actor: User, email: string) { + const token = await UserTokens.create( + 'invite', + new Date(Date.now() + 3600 * 1000), + actor.id, + undefined, + `clientmanager=${client.client_id}` + ); + const params = new URLSearchParams({ token: token.token }); + const content = OAuth2InvitationEmail( + actor.display_name, + client.title, + `${PUBLIC_URL}/ssoadmin/oauth2/invite?${params.toString()}` + ); + + // TODO: logging + try { + await Emails.getSender().sendTemplate( + email, + `You have been invited to manage "${client.title}" on ${PUBLIC_SITE_NAME}`, + content + ); + } catch { + await UserTokens.remove(token); + } + } + static async authorizeClientInfo(client: OAuth2Client, scope: string[]) { const links = await OAuth2Clients.getClientUrls(client); const filteredLinks = links diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts index fd03e7b..4d53d4b 100644 --- a/src/lib/server/users/index.ts +++ b/src/lib/server/users/index.ts @@ -194,10 +194,11 @@ export class Users { static async sendInvitationEmail(email: string) { const token = await UserTokens.create( - 'login', + 'invite', new Date(Date.now() + 3600 * 1000), undefined, - email + undefined, + `register=${email}` ); const params = new URLSearchParams({ token: token.token }); const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`); diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts index 4bc4fed..8376612 100644 --- a/src/lib/server/users/tokens.ts +++ b/src/lib/server/users/tokens.ts @@ -7,7 +7,8 @@ export class UserTokens { type: (typeof userToken.$inferInsert)['type'], expires: Date, userId?: number, - nonce?: string + nonce?: string, + metadata?: string ) { const token = CryptoUtils.generateString(64); const obj = { @@ -15,7 +16,8 @@ export class UserTokens { token, userId, expires_at: expires, - nonce + nonce, + metadata }; const [retval] = await db.insert(userToken).values(obj); return { id: retval.insertId, ...obj } as UserToken; diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts b/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts index 79c949b..9f466f9 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts @@ -7,7 +7,7 @@ import { Uploads } from '$lib/server/upload.js'; import { Users } from '$lib/server/users'; import { UsersAdmin } from '$lib/server/users/admin'; import { hasPrivileges } from '$lib/utils'; -import { privilegeRegex } from '$lib/validators.js'; +import { emailRegex, privilegeRegex } from '$lib/validators.js'; import { error, fail, redirect } from '@sveltejs/kit'; interface AddUrlRequest { @@ -26,6 +26,10 @@ interface AddPrivilegeRequest { name: string; } +interface InviteRequest { + email: string; +} + export const actions = { update: async ({ locals, request, params: { uuid } }) => { const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ @@ -380,6 +384,69 @@ export const actions = { await Uploads.removeClientAvatar(details as OAuth2Client); + return { errors: [] }; + }, + invite: async ({ locals, request, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: fullPrivileges, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const body = await request.formData(); + const { email } = Changesets.take(['email'], body); + + if (!email || !emailRegex.test(email)) { + return fail(400, { errors: ['invalidEmail'] }); + } + + const managers = await OAuth2Clients.getManagers(details as OAuth2Client); + if (managers.some((entry) => entry.email.toLowerCase() === email.toLowerCase())) { + return fail(400, { errors: ['emailExists'] }); + } + + await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email); + + return { errors: [] }; + }, + removeManager: async ({ locals, url, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: fullPrivileges, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const id = Number(url.searchParams.get('id')); + if (isNaN(id)) { + return fail(400, { errors: ['invalidManagerId'] }); + } + + await OAuth2Clients.removeManager(details as OAuth2Client, id); + return { errors: [] }; } }; @@ -405,9 +472,11 @@ export const load = async ({ params: { uuid }, parent }) => { const privileges = await Users.getAvailablePrivileges(details.id); const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client); + const managers = await OAuth2Clients.getManagers(details as OAuth2Client); return { users, + managers, availableUrls: OAuth2Clients.availableUrlTypes, availablePrivileges: privileges, availableGrants: fullPrivileges diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte index f9d8bd4..243a8e0 100644 --- a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte @@ -23,6 +23,7 @@ let secret = false; let addingUrl = false; let addingPrivilege = false; + let addingManager = false; $: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri'); $: availableUrls = data.availableUrls.filter((type) => { @@ -290,44 +291,97 @@ -

{$t('admin.oauth2.apis.title')}

- + + {#if data.fullPrivileges || data.details.isOwner} + +

{$t('admin.oauth2.managers.title')}

+

{$t('admin.oauth2.managers.hint', { siteName: PUBLIC_SITE_NAME })}

+ +
+ {#each data.managers as user} +
+ {user.email} +
+ +
+
+ {/each} +
+ + {#if addingManager} +
+ + + + + + + + + + + + +
+ {:else} +
+ +
+ {/if} +
+ {/if} + + +

{$t('admin.oauth2.apis.title')}

+ +
+
+

{$t('admin.oauth2.authorizations')}

{$t('admin.oauth2.authorizationsHint')}

diff --git a/src/routes/ssoadmin/oauth2/invite/+server.ts b/src/routes/ssoadmin/oauth2/invite/+server.ts new file mode 100644 index 0000000..90d8740 --- /dev/null +++ b/src/routes/ssoadmin/oauth2/invite/+server.ts @@ -0,0 +1,39 @@ +import { ApiUtils } from '$lib/server/api-utils.js'; +import { OAuth2Clients } from '$lib/server/oauth2/index.js'; +import { UserTokens, Users } from '$lib/server/users'; + +export const GET = async ({ locals, url }) => { + const userInfo = locals.session.data?.user; + const currentUser = await Users.getBySession(userInfo); + if (!userInfo || !currentUser) { + await locals.session.destroy(); + return ApiUtils.redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + const token = url.searchParams.get('token'); + if (!token) { + return ApiUtils.redirect('/'); + } + + const fetch = await UserTokens.getByToken(token, 'invite'); + if (!fetch?.userId || !fetch.metadata?.startsWith('clientmanager=')) { + return ApiUtils.redirect('/'); + } + + const inviter = await Users.getById(fetch.userId); + if (!inviter) { + return ApiUtils.redirect('/'); + } + + const [, clientId] = fetch.metadata.split('='); + const client = await OAuth2Clients.fetchById(clientId); + if (!client) { + return ApiUtils.redirect('/'); + } + + await OAuth2Clients.addManager(client, inviter, currentUser); + await UserTokens.remove(fetch); + + console.log('?'); + return ApiUtils.redirect(`/ssoadmin/oauth2/${client.client_id}`); +};