diff --git a/.env.example b/.env.example index e827eab..a05b51c 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ FF_PORTAL_API_KEYS=false FF_PORTAL_ORG_INFO=false FF_PORTAL_EMAIL_LOG=false FF_PORTAL_DNS=false +FF_PORTAL_MEMBERS=false FF_ADMIN_PANEL=false FF_ADMIN_ORG_STATUS=false FF_ADMIN_AUDIT_LOG=false diff --git a/drizzle/migrations/0001_add-users-table.sql b/drizzle/migrations/0001_add-users-table.sql new file mode 100644 index 0000000..2de59c8 --- /dev/null +++ b/drizzle/migrations/0001_add-users-table.sql @@ -0,0 +1,22 @@ +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" varchar(256) NOT NULL, + "full_name" varchar(256) NOT NULL, + "phone" varchar(32), + "org_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "organizations" RENAME COLUMN "email" TO "signing_email"; -- safe: new column name, code updated in same deploy +--> statement-breakpoint +ALTER TABLE "organizations" ADD COLUMN "contact_user_id" uuid;--> statement-breakpoint +ALTER TABLE "organizations" DROP COLUMN "contact_name"; -- safe: replaced by users.full_name via contact_user_id +--> statement-breakpoint +ALTER TABLE "organizations" DROP COLUMN "phone"; -- safe: moved to users table +--> statement-breakpoint +ALTER TABLE "sessions" ADD COLUMN "user_id" uuid;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_users_org" ON "users" USING btree ("org_id"); -- safe: new table, no existing rows +--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; diff --git a/drizzle/migrations/meta/0001_snapshot.json b/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..54ba23e --- /dev/null +++ b/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,1009 @@ +{ + "id": "11be2121-94a3-460c-802c-2c82a6f04d9f", + "prevId": "3c120143-8459-4f4b-9770-84b4fa480179", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_accounts": { + "name": "admin_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_accounts_email_unique": { + "name": "admin_accounts_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_admin_audit_admin": { + "name": "idx_admin_audit_admin", + "columns": [ + { + "expression": "admin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_admin_audit_created": { + "name": "idx_admin_audit_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_admin_id_admin_accounts_id_fk": { + "name": "admin_audit_log_admin_id_admin_accounts_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "admin_accounts", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_api_keys": { + "name": "business_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signing_attrs": { + "name": "signing_attrs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_keys_org": { + "name": "idx_api_keys_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "business_api_keys_org_id_organizations_id_fk": { + "name": "business_api_keys_org_id_organizations_id_fk", + "tableFrom": "business_api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "business_api_keys_created_by_admin_accounts_id_fk": { + "name": "business_api_keys_created_by_admin_accounts_id_fk", + "tableFrom": "business_api_keys", + "tableTo": "admin_accounts", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "business_api_keys_key_hash_unique": { + "name": "business_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.change_requests": { + "name": "change_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "field_name": { + "name": "field_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "review_notes": { + "name": "review_notes", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_change_requests_org": { + "name": "idx_change_requests_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_change_requests_status": { + "name": "idx_change_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "change_requests_org_id_organizations_id_fk": { + "name": "change_requests_org_id_organizations_id_fk", + "tableFrom": "change_requests", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "change_requests_reviewed_by_admin_accounts_id_fk": { + "name": "change_requests_reviewed_by_admin_accounts_id_fk", + "tableFrom": "change_requests", + "tableTo": "admin_accounts", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dns_verifications": { + "name": "dns_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "txt_record": { + "name": "txt_record", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dns_verifications_org_id_organizations_id_fk": { + "name": "dns_verifications_org_id_organizations_id_fk", + "tableFrom": "dns_verifications", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dns_verifications_org_id_unique": { + "name": "dns_verifications_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "org_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_audit_log": { + "name": "email_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient": { + "name": "recipient", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_email_log_org": { + "name": "idx_email_log_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_log_signed": { + "name": "idx_email_log_signed", + "columns": [ + { + "expression": "signed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_audit_log_org_id_organizations_id_fk": { + "name": "email_audit_log_org_id_organizations_id_fk", + "tableFrom": "email_audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_audit_log_api_key_id_business_api_keys_id_fk": { + "name": "email_audit_log_api_key_id_business_api_keys_id_fk", + "tableFrom": "email_audit_log", + "tableTo": "business_api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "signing_email": { + "name": "signing_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "kvk_number": { + "name": "kvk_number", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "contact_user_id": { + "name": "contact_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_domain_unique": { + "name": "organizations_domain_unique", + "nullsNotDistinct": false, + "columns": [ + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "user_type": { + "name": "user_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "impersonating_org_id": { + "name": "impersonating_org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "yivi_attributes": { + "name": "yivi_attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_expires": { + "name": "idx_sessions_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_org_id_organizations_id_fk": { + "name": "sessions_org_id_organizations_id_fk", + "tableFrom": "sessions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_admin_id_admin_accounts_id_fk": { + "name": "sessions_admin_id_admin_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "admin_accounts", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_impersonating_org_id_organizations_id_fk": { + "name": "sessions_impersonating_org_id_organizations_id_fk", + "tableFrom": "sessions", + "tableTo": "organizations", + "columnsFrom": [ + "impersonating_org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_org": { + "name": "idx_users_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_org_id_organizations_id_fk": { + "name": "users_org_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index c637a86..82a81a1 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776509402747, "tag": "0000_overconfident_firedrake", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1776858837490, + "tag": "0001_add-users-table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/check-migrations.ts b/scripts/check-migrations.ts index cef5e1a..3052019 100644 --- a/scripts/check-migrations.ts +++ b/scripts/check-migrations.ts @@ -127,8 +127,9 @@ function checkFile(filePath: string): Violation[] { for (let i = 0; i < lines.length; i++) { const line = lines[i]; - // Skip comments + // Skip comments and lines with safe: annotation if (line.trim().startsWith('--')) continue; + if (line.includes('-- safe:')) continue; for (const rule of RULES) { if (rule.pattern.test(line)) { diff --git a/scripts/migrate-legacy-api-keys.ts b/scripts/migrate-legacy-api-keys.ts index 155936c..887e80e 100644 --- a/scripts/migrate-legacy-api-keys.ts +++ b/scripts/migrate-legacy-api-keys.ts @@ -17,7 +17,7 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import { eq } from 'drizzle-orm'; -import { organizations, apiKeys } from '../src/lib/server/db/schema.ts'; +import { organizations, users, apiKeys } from '../src/lib/server/db/schema.ts'; import { planMigration, type LegacyApiKeyRow @@ -118,15 +118,30 @@ async function main() { .values({ name: g.name, domain: g.domain, - email: g.email, - contactName: g.contactName, - phone: g.phone ?? null, + signingEmail: g.signingEmail, kvkNumber: g.kvkNumber ?? null, status: 'active' }) .returning({ id: organizations.id }); orgId = inserted.id; - console.log(`org: created "${g.domain}" id=${orgId}`); + + // Create a user for the contact person and link them + const [user] = await tx + .insert(users) + .values({ + email: g.contactEmail, + fullName: g.contactName, + phone: g.contactPhone ?? null, + orgId + }) + .returning({ id: users.id }); + + await tx + .update(organizations) + .set({ contactUserId: user.id }) + .where(eq(organizations.id, orgId)); + + console.log(`org: created "${g.domain}" id=${orgId} contact=${user.id}`); } for (const h of g.memberKeyHashes) { diff --git a/scripts/seed.ts b/scripts/seed.ts index 79f7c3e..acc0f2a 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto'; import { adminAccounts, organizations, + users, apiKeys, dnsVerifications } from '../src/lib/server/db/schema.ts'; @@ -62,9 +63,7 @@ async function main() { .values({ name: 'Acme B.V.', domain: 'acme.example.nl', - email: orgEmail, - contactName: 'Jan de Vries', - phone: '+31612345678', + signingEmail: orgEmail, kvkNumber: '12345678', status: 'active' }) @@ -72,12 +71,32 @@ async function main() { .returning(); if (org) { + // 2b. Create a user for this org and set as contact person + const [user] = await db + .insert(users) + .values({ + email: orgEmail, + fullName: 'Jan de Vries', + phone: '+31612345678', + orgId: org.id + }) + .onConflictDoNothing({ target: users.email }) + .returning(); + + if (user) { + await db + .update(organizations) + .set({ contactUserId: user.id }) + .where(eq(organizations.id, org.id)); + } + console.log('Example organization:'); console.log(' Name: Acme B.V.'); console.log(' Domain: acme.example.nl'); - console.log(` Email: ${orgEmail}`); + console.log(` Signing email: ${orgEmail}`); console.log(' Status: active'); console.log(' KVK: 12345678'); + console.log(` Contact: Jan de Vries (${orgEmail})`); console.log(` Log in as org by disclosing email: ${orgEmail}\n`); // 3. Example API key diff --git a/src/app.d.ts b/src/app.d.ts index 9815ef9..9538e9f 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -7,6 +7,7 @@ declare global { session: { id: string; userType: 'org' | 'admin'; + userId: string | null; orgId: string | null; adminId: string | null; impersonatingOrgId: string | null; diff --git a/src/lib/components/YiviLogin.svelte b/src/lib/components/YiviLogin.svelte index 709ca77..33e5f6b 100644 --- a/src/lib/components/YiviLogin.svelte +++ b/src/lib/components/YiviLogin.svelte @@ -142,8 +142,11 @@ } .yivi-container { - min-width: 260px; - min-height: 260px; + background: #fff; + border: 1px solid var(--pg-strong-background); + border-radius: var(--pg-border-radius-lg); + padding: 4px; + overflow: hidden; } .waiting-text { diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index a9cfa00..ca99768 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -8,6 +8,7 @@ export type FeatureFlag = | 'portalOrgInfo' | 'portalEmailLog' | 'portalDns' + | 'portalMembers' | 'adminPanel' | 'adminOrgStatus' | 'adminAuditLog' @@ -20,6 +21,7 @@ export const FLAG_LABELS: Record = { portalOrgInfo: 'Portal: Organization info', portalEmailLog: 'Portal: Email audit log', portalDns: 'Portal: DNS verification', + portalMembers: 'Portal: Members', adminPanel: 'Admin panel', adminOrgStatus: 'Admin: Org status (activate/suspend)', adminAuditLog: 'Admin: Audit log', @@ -34,6 +36,7 @@ const envFlags: Record = { portalOrgInfo: env.FF_PORTAL_ORG_INFO === 'true', portalEmailLog: env.FF_PORTAL_EMAIL_LOG === 'true', portalDns: env.FF_PORTAL_DNS === 'true', + portalMembers: env.FF_PORTAL_MEMBERS === 'true', adminPanel: env.FF_ADMIN_PANEL === 'true', adminOrgStatus: env.FF_ADMIN_ORG_STATUS === 'true', adminAuditLog: env.FF_ADMIN_AUDIT_LOG === 'true', diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index 7a632fd..bfef3a1 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -1,11 +1,12 @@ import { db } from '$lib/server/db'; -import { sessions, organizations, adminAccounts } from '$lib/server/db/schema'; +import { sessions, organizations, adminAccounts, users } from '$lib/server/db/schema'; import { eq, and, gt } from 'drizzle-orm'; import { randomBytes, createHash } from 'crypto'; export interface SessionData { id: string; userType: 'org' | 'admin'; + userId: string | null; orgId: string | null; adminId: string | null; impersonatingOrgId: string | null; @@ -24,6 +25,7 @@ export function generateToken(): string { export async function createSession( userType: 'org' | 'admin', + userId: string | null, orgId: string | null, adminId: string | null, yiviAttributes: Record @@ -35,6 +37,7 @@ export async function createSession( await db.insert(sessions).values({ tokenHash, userType, + userId, orgId, adminId, yiviAttributes, @@ -66,6 +69,7 @@ export async function resolveSession(token: string): Promise return { id: session.id, userType: session.userType as 'org' | 'admin', + userId: session.userId, orgId: session.orgId, adminId: session.adminId, impersonatingOrgId: session.impersonatingOrgId, @@ -78,15 +82,6 @@ export async function destroySession(token: string): Promise { await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash)); } -export async function findOrgByEmail(email: string) { - const result = await db - .select() - .from(organizations) - .where(eq(organizations.email, email)) - .limit(1); - return result[0] ?? null; -} - export async function findAdminByAttributes( email: string, fullName: string, diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 8a06f15..1e2a305 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -4,15 +4,29 @@ export const organizations = pgTable('organizations', { id: uuid('id').primaryKey().defaultRandom(), name: varchar('name', { length: 256 }).notNull(), domain: varchar('domain', { length: 256 }).notNull().unique(), - email: varchar('email', { length: 256 }).notNull(), - contactName: varchar('contact_name', { length: 256 }).notNull(), - phone: varchar('phone', { length: 32 }), + signingEmail: varchar('signing_email', { length: 256 }).notNull(), kvkNumber: varchar('kvk_number', { length: 32 }), + contactUserId: uuid('contact_user_id'), status: varchar('status', { length: 32 }).notNull().default('pending'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() }); +export const users = pgTable( + 'users', + { + id: uuid('id').primaryKey().defaultRandom(), + email: varchar('email', { length: 256 }).notNull().unique(), + fullName: varchar('full_name', { length: 256 }).notNull(), + phone: varchar('phone', { length: 32 }), + orgId: uuid('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow() + }, + (table) => [index('idx_users_org').on(table.orgId)] +); + export const adminAccounts = pgTable('admin_accounts', { id: uuid('id').primaryKey().defaultRandom(), email: varchar('email', { length: 256 }).notNull().unique(), @@ -48,6 +62,7 @@ export const sessions = pgTable( id: uuid('id').primaryKey().defaultRandom(), tokenHash: varchar('token_hash', { length: 128 }).notNull().unique(), userType: varchar('user_type', { length: 16 }).notNull(), + userId: uuid('user_id').references(() => users.id), orgId: uuid('org_id').references(() => organizations.id), adminId: uuid('admin_id').references(() => adminAccounts.id), impersonatingOrgId: uuid('impersonating_org_id').references(() => organizations.id), diff --git a/src/lib/server/migrations/legacy-api-keys.ts b/src/lib/server/migrations/legacy-api-keys.ts index 5ef98da..25b0706 100644 --- a/src/lib/server/migrations/legacy-api-keys.ts +++ b/src/lib/server/migrations/legacy-api-keys.ts @@ -44,9 +44,10 @@ export type PlannedKey = { export type OrgGroup = { name: string; domain: string; - email: string; + signingEmail: string; + contactEmail: string; contactName: string; - phone: string | null; + contactPhone: string | null; kvkNumber: string | null; memberKeyHashes: string[]; }; @@ -102,15 +103,15 @@ export function groupingKey(row: LegacyApiKeyRow): string { * back to `.legacy.postguard.local`. */ export function synthesiseDomain(group: OrgGroup): string { - if (group.email) { - const at = group.email.indexOf('@'); - if (at > 0 && at < group.email.length - 1) { - const dom = group.email.substring(at + 1).toLowerCase(); + if (group.signingEmail) { + const at = group.signingEmail.indexOf('@'); + if (at > 0 && at < group.signingEmail.length - 1) { + const dom = group.signingEmail.substring(at + 1).toLowerCase(); if (isPlausibleDomain(dom)) return dom; } } - const slug = slugify(group.kvkNumber ?? group.name ?? group.email); + const slug = slugify(group.kvkNumber ?? group.name ?? group.signingEmail); return `${slug}.legacy.postguard.local`; } @@ -182,9 +183,10 @@ export function planMigration(rows: LegacyApiKeyRow[]): MigrationPlan { name: row.organisation_name?.trim() || (row.kvk_number ? `KVK ${row.kvk_number}` : row.email), domain: '', // filled below - email: row.email, + signingEmail: row.email, + contactEmail: row.email, contactName: row.email, - phone: row.phone_number, + contactPhone: row.phone_number, kvkNumber: row.kvk_number, memberKeyHashes: [] }; diff --git a/src/lib/server/services/admin.ts b/src/lib/server/services/admin.ts index 35c0fe8..8d27b26 100644 --- a/src/lib/server/services/admin.ts +++ b/src/lib/server/services/admin.ts @@ -1,6 +1,7 @@ import { db } from '$lib/server/db'; import { organizations, + users, changeRequests, adminAuditLog, apiKeys, @@ -60,7 +61,18 @@ export async function getOrganizationWithRequests(orgId: string) { .where(eq(changeRequests.orgId, orgId)) .orderBy(desc(changeRequests.requestedAt)); - return { organization: orgs[0], requests }; + // Load contact person if set + let contactPerson = null; + if (orgs[0].contactUserId) { + const result = await db + .select() + .from(users) + .where(eq(users.id, orgs[0].contactUserId)) + .limit(1); + contactPerson = result[0] ?? null; + } + + return { organization: orgs[0], requests, contactPerson }; } export async function approveChangeRequest( @@ -82,9 +94,7 @@ export async function approveChangeRequest( const fieldMap: Record = { name: 'name', domain: 'domain', - email: 'email', - contactName: 'contact_name', - phone: 'phone', + signingEmail: 'signing_email', kvkNumber: 'kvk_number' }; diff --git a/src/lib/server/services/users.ts b/src/lib/server/services/users.ts new file mode 100644 index 0000000..14a33b6 --- /dev/null +++ b/src/lib/server/services/users.ts @@ -0,0 +1,71 @@ +import { db } from '$lib/server/db'; +import { users, organizations } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; + +export async function findUserByEmail(email: string) { + const result = await db + .select({ + user: users, + org: organizations + }) + .from(users) + .innerJoin(organizations, eq(users.orgId, organizations.id)) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + return result[0] ?? null; +} + +export async function listOrgUsers(orgId: string) { + return db.select().from(users).where(eq(users.orgId, orgId)); +} + +export async function addUser( + orgId: string, + email: string, + fullName: string, + phone: string | null +) { + const [user] = await db + .insert(users) + .values({ + email: email.toLowerCase(), + fullName, + phone, + orgId + }) + .returning(); + return user; +} + +export async function removeUser(userId: string, orgId: string) { + // Check this user isn't the contact person + const [org] = await db + .select({ contactUserId: organizations.contactUserId }) + .from(organizations) + .where(eq(organizations.id, orgId)) + .limit(1); + + if (org?.contactUserId === userId) { + throw new Error('Cannot remove the contact person. Assign a different contact person first.'); + } + + await db.delete(users).where(and(eq(users.id, userId), eq(users.orgId, orgId))); +} + +export async function setContactPerson(orgId: string, userId: string) { + // Verify the user belongs to this org + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, userId), eq(users.orgId, orgId))) + .limit(1); + + if (!user) { + throw new Error('User does not belong to this organization.'); + } + + await db + .update(organizations) + .set({ contactUserId: userId, updatedAt: new Date() }) + .where(eq(organizations.id, orgId)); +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index d7bb51d..e91dad2 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -2,15 +2,23 @@ export interface Organization { id: string; name: string; domain: string; - email: string; - contactName: string; - phone: string | null; + signingEmail: string; kvkNumber: string | null; + contactUserId: string | null; status: 'pending' | 'active' | 'suspended'; createdAt: Date; updatedAt: Date; } +export interface User { + id: string; + email: string; + fullName: string; + phone: string | null; + orgId: string; + createdAt: Date; +} + export interface ApiKey { id: string; keyPrefix: string; diff --git a/src/routes/(admin)/admin/organizations/+page.svelte b/src/routes/(admin)/admin/organizations/+page.svelte index 7399f8f..d9e1491 100644 --- a/src/routes/(admin)/admin/organizations/+page.svelte +++ b/src/routes/(admin)/admin/organizations/+page.svelte @@ -39,7 +39,7 @@ Name Domain - Email + Signing email {#if data.orgStatusEnabled}Status{/if} Created {#if data.orgStatusEnabled}{/if} @@ -50,7 +50,7 @@ {org.name} {org.domain} - {org.email} + {org.signingEmail} {#if data.orgStatusEnabled} diff --git a/src/routes/(admin)/admin/organizations/[id]/+page.svelte b/src/routes/(admin)/admin/organizations/[id]/+page.svelte index 0091b86..2de7071 100644 --- a/src/routes/(admin)/admin/organizations/[id]/+page.svelte +++ b/src/routes/(admin)/admin/organizations/[id]/+page.svelte @@ -24,10 +24,9 @@
Domain{data.organization.domain}
-
Email{data.organization.email}
-
Contact{data.organization.contactName}
-
Phone{data.organization.phone ?? '—'}
+
Signing email{data.organization.signingEmail}
KVK{data.organization.kvkNumber ?? '—'}
+
Contact{data.contactPerson ? `${data.contactPerson.fullName} (${data.contactPerson.email})` : '—'}
{#if data.impersonationEnabled} @@ -132,16 +131,17 @@ } .detail { - display: flex; + display: grid; + grid-template-columns: 120px 1fr; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--pg-strong-background); font-size: var(--pg-font-size-md); + align-items: baseline; &:last-child { border-bottom: none; } } .label { - min-width: 80px; font-size: var(--pg-font-size-xs); color: var(--pg-text-secondary); font-weight: var(--pg-font-weight-medium); diff --git a/src/routes/(marketing)/register/+page.server.ts b/src/routes/(marketing)/register/+page.server.ts index c241213..6eaa98b 100644 --- a/src/routes/(marketing)/register/+page.server.ts +++ b/src/routes/(marketing)/register/+page.server.ts @@ -1,7 +1,8 @@ import type { Actions } from './$types'; import { fail } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { organizations } from '$lib/server/db/schema'; +import { organizations, users } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; import { isEnabled } from '$lib/feature-flags'; import { error } from '@sveltejs/kit'; @@ -16,7 +17,7 @@ export const actions: Actions = { if (!isEnabled('registration')) { return fail(404, { errors: { form: 'Not found' } as Record, - values: { name: '', domain: '', email: '', contactName: '', phone: null, kvkNumber: null } + values: { name: '' } }); } @@ -26,53 +27,62 @@ export const actions: Actions = { const email = data.get('email')?.toString().trim().toLowerCase(); const contactName = data.get('contactName')?.toString().trim(); const phone = data.get('phone')?.toString().trim() || null; - const kvkNumber = data.get('kvkNumber')?.toString().trim() || null; // Validation const errors: Record = {}; if (!name) errors.name = 'Organization name is required'; - if (!domain) errors.domain = 'Domain is required'; - if (!email) errors.email = 'Email is required'; - if (!contactName) errors.contactName = 'Contact name is required'; - - if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errors.email = 'Invalid email address'; - } + if (!domain) errors.domain = 'Could not derive domain from email'; + if (!email) errors.form = 'Email attribute missing — please verify with Yivi first'; + if (!contactName) errors.form = 'Name attribute missing — please verify with Yivi first'; if (domain && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/.test(domain)) { - errors.domain = 'Invalid domain name'; - } - - if (kvkNumber && !/^\d{8}$/.test(kvkNumber)) { - errors.kvkNumber = 'KvK number must be 8 digits'; + errors.domain = 'Invalid domain derived from email'; } if (Object.keys(errors).length > 0) { return fail(400, { errors, - values: { name, domain, email, contactName, phone, kvkNumber } + values: { name } }); } try { - await db.insert(organizations).values({ - name: name!, - domain: domain!, - email: email!, - contactName: contactName!, - phone, - kvkNumber - }); + const [org] = await db + .insert(organizations) + .values({ + name: name!, + domain: domain!, + signingEmail: email! + }) + .returning({ id: organizations.id }); + + const [user] = await db + .insert(users) + .values({ + email: email!, + fullName: contactName!, + phone, + orgId: org.id + }) + .returning({ id: users.id }); + + await db + .update(organizations) + .set({ contactUserId: user.id }) + .where(eq(organizations.id, org.id)); } catch (err: unknown) { - if (err instanceof Error && err.message.includes('unique')) { + const errStr = String(err instanceof Error ? err.stack ?? err.message : err); + const cause = (err as any)?.cause; + const causeStr = cause ? String(cause.message ?? cause) : ''; + if (errStr.includes('unique') || errStr.includes('duplicate key') || causeStr.includes('duplicate key')) { return fail(409, { errors: { domain: 'This domain is already registered' } as Record, - values: { name, domain, email, contactName, phone, kvkNumber } + values: { name } }); } return fail(500, { errors: { form: 'An unexpected error occurred. Please try again.' } as Record, - values: { name, domain, email, contactName, phone, kvkNumber } + values: { name } }); } diff --git a/src/routes/(marketing)/register/+page.svelte b/src/routes/(marketing)/register/+page.svelte index aff84c3..5b4af01 100644 --- a/src/routes/(marketing)/register/+page.svelte +++ b/src/routes/(marketing)/register/+page.svelte @@ -2,6 +2,10 @@ import SEO from '$lib/components/SEO.svelte'; import { enhance } from '$app/forms'; import Icon from '@iconify/svelte'; + import '@privacybydesign/yivi-css'; + import type { PageData } from './$types'; + + let { data, form }: { data: PageData; form: FormResult | null } = $props(); interface FormResult { success?: boolean; @@ -9,7 +13,104 @@ values?: Record; } - let { form }: { form: FormResult | null } = $props(); + let yiviStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle'); + let yiviError = $state(''); + let disclosed = $state<{ email: string; fullName: string; phone: string | null } | null>(null); + let derivedDomain = $state(''); + + async function startYiviFlow() { + yiviStatus = 'running'; + yiviError = ''; + + try { + const { YiviCore } = await import('@privacybydesign/yivi-core'); + const { YiviWeb } = await import('@privacybydesign/yivi-web'); + const { YiviClient } = await import('@privacybydesign/yivi-client'); + + const attrs = data.yiviAttrs; + let irmaToken = ''; + + const yivi = new YiviCore({ + debugging: false, + element: '#yivi-register-container', + language: 'en', + minimal: true, + session: { + url: '/irma', + start: { + url: (o: any) => `${o.url}/session`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + '@context': 'https://irma.app/ld/request/disclosure/v2', + disclose: [[[attrs.email]], [[attrs.fullName]], [[attrs.phone]]] + }) + }, + mapping: { + sessionPtr: (r: any) => r.sessionPtr, + sessionToken: (r: any) => { + irmaToken = r.token; + return r.token; + }, + frontendRequest: (r: any) => r.frontendRequest + } + }, + state: { + serverSentEvents: false, + polling: { + endpoint: 'status', + interval: 1000, + startState: 'INITIALIZED' + } + } + }); + + yivi.use(YiviWeb); + yivi.use(YiviClient); + + await yivi.start(); + + // Verify the disclosure and get attributes + const response = await fetch('/api/auth/yivi/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ irmaSessionToken: irmaToken }) + }); + + if (!response.ok) { + const err = await response.json(); + yiviStatus = 'error'; + yiviError = err.message ?? 'Verification failed'; + return; + } + + const { attributes } = await response.json(); + disclosed = { + email: attributes.email, + fullName: attributes.fullName, + phone: attributes.phone ?? null + }; + + // Derive domain from email + const at = attributes.email.indexOf('@'); + if (at > 0) { + derivedDomain = attributes.email.substring(at + 1).toLowerCase(); + } + + yiviStatus = 'done'; + } catch (e: unknown) { + if (yiviStatus === 'running') { + yiviStatus = 'error'; + yiviError = e instanceof Error ? e.message : 'Yivi session failed. Please try again.'; + } + } + } + + function retryYivi() { + yiviStatus = 'idle'; + yiviError = ''; + disclosed = null; + } Back to home - {:else} + {:else if yiviStatus === 'idle' || yiviStatus === 'running' || yiviStatus === 'error'}

Register your organization

- Fill in your organization details to apply for PostGuard for Business. We'll review - your application and get back to you shortly. + Verify your identity with the Yivi app to register your organization for PostGuard for Business. +

+ + {#if yiviStatus === 'idle'} +
+ +
+ {:else if yiviStatus === 'running'} +
+
+

Please disclose your email, full name, and phone number.

+
+ {:else if yiviStatus === 'error'} +
+ +

{yiviError}

+ +
+ {/if} + {:else if disclosed} +

Complete registration

+

+ Your identity has been verified. Fill in the remaining details below.

{#if form?.errors?.form} @@ -44,7 +169,33 @@ {/if} +
+
+ Full name + {disclosed.fullName} +
+
+ Email + {disclosed.email} +
+ {#if disclosed.phone} +
+ Phone + {disclosed.phone} +
+ {/if} +
+ + Verified with Yivi +
+
+
+ + + + +
-
- - - {#if form?.errors?.domain} - {form.errors.domain} - {/if} -
- -
- - - {#if form?.errors?.email} - {form.errors.email} - {/if} -
- -
- - - {#if form?.errors?.contactName} - {form.errors.contactName} - {/if} -
- -
-
- - + {#if form?.errors?.domain} + - -
- - - {#if form?.errors?.kvkNumber} - {form.errors.kvkNumber} - {/if} -
-
+ {/if} @@ -174,6 +254,92 @@ line-height: 1.5; } + .yivi-section { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem 0; + } + + .yivi-btn { + gap: 0.5rem; + } + + .yivi-container { + background: #fff; + border: 1px solid var(--pg-strong-background); + border-radius: var(--pg-border-radius-lg); + padding: 4px; + overflow: hidden; + } + + .yivi-hint { + max-width: 300px; + text-align: center; + line-height: 1.5; + color: var(--pg-text-secondary); + margin-top: 1rem; + } + + .error-state { + gap: 1rem; + text-align: center; + + :global(svg) { + color: var(--pg-input-error); + } + + p { + color: var(--pg-input-error); + } + } + + .disclosed-info { + background: var(--pg-soft-background); + border-radius: var(--pg-border-radius-lg); + padding: 1.25rem; + margin-bottom: 1.5rem; + } + + .disclosed-field { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--pg-strong-background); + + &:last-of-type { + border-bottom: none; + } + } + + .disclosed-label { + font-size: var(--pg-font-size-xs); + color: var(--pg-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: var(--pg-font-weight-medium); + } + + .disclosed-value { + font-size: var(--pg-font-size-md); + font-weight: var(--pg-font-weight-medium); + } + + .disclosed-badge { + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.75rem; + font-size: var(--pg-font-size-xs); + font-weight: var(--pg-font-weight-bold); + color: #16a34a; + + :global(svg) { + color: #16a34a; + } + } + .form-error { display: flex; align-items: center; @@ -205,16 +371,6 @@ } } - .form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - - @media only screen and (max-width: 500px) { - grid-template-columns: 1fr; - } - } - .field-error { font-size: var(--pg-font-size-xs); color: var(--pg-input-error); diff --git a/src/routes/(portal)/+layout.server.ts b/src/routes/(portal)/+layout.server.ts index 0e95f00..58d4913 100644 --- a/src/routes/(portal)/+layout.server.ts +++ b/src/routes/(portal)/+layout.server.ts @@ -1,7 +1,7 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; import { db } from '$lib/server/db'; -import { organizations } from '$lib/server/db/schema'; +import { organizations, users } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { isEnabled } from '$lib/feature-flags'; @@ -31,23 +31,51 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { redirect(303, '/auth/login'); } + // Load the current user (for org users) or the contact person (for impersonating admins) + let user = null; + if (isOrgUser && locals.session.userId) { + const result = await db + .select() + .from(users) + .where(eq(users.id, locals.session.userId)) + .limit(1); + user = result[0] ?? null; + } + + // Load contact person + let contactPerson = null; + if (org.contactUserId) { + const result = await db + .select() + .from(users) + .where(eq(users.id, org.contactUserId)) + .limit(1); + contactPerson = result[0] + ? { id: result[0].id, fullName: result[0].fullName, email: result[0].email, phone: result[0].phone } + : null; + } + return { organization: { id: org.id, name: org.name, domain: org.domain, - email: org.email, - contactName: org.contactName, - phone: org.phone, + signingEmail: org.signingEmail, kvkNumber: org.kvkNumber, + contactUserId: org.contactUserId, status: org.status }, + user: user + ? { id: user.id, email: user.email, fullName: user.fullName, phone: user.phone } + : null, + contactPerson, isImpersonating: !!isImpersonating, featureFlags: { apiKeys: isEnabled('portalApiKeys'), orgInfo: isEnabled('portalOrgInfo'), emailLog: isEnabled('portalEmailLog'), - dns: isEnabled('portalDns') + dns: isEnabled('portalDns'), + members: isEnabled('portalMembers') } }; }; diff --git a/src/routes/(portal)/+layout.svelte b/src/routes/(portal)/+layout.svelte index 152223c..5e43cde 100644 --- a/src/routes/(portal)/+layout.svelte +++ b/src/routes/(portal)/+layout.svelte @@ -19,6 +19,9 @@ ...(data.featureFlags.emailLog ? [{ href: '/portal/email-log', label: 'Email Log', icon: 'mdi:email-search' }] : []), + ...(data.featureFlags.members + ? [{ href: '/portal/members', label: 'Members', icon: 'mdi:account-group' }] + : []), ...(data.featureFlags.dns ? [{ href: '/portal/dns', label: 'DNS Verification', icon: 'mdi:dns' }] : []) diff --git a/src/routes/(portal)/portal/api-keys/+page.server.ts b/src/routes/(portal)/portal/api-keys/+page.server.ts index da71455..ed76573 100644 --- a/src/routes/(portal)/portal/api-keys/+page.server.ts +++ b/src/routes/(portal)/portal/api-keys/+page.server.ts @@ -12,7 +12,7 @@ export const load: PageServerLoad = async ({ parent }) => { export const actions: Actions = { revoke: async ({ request, locals }) => { - const orgId = locals.session?.orgId; + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; if (!orgId) error(401, 'Not authenticated'); const data = await request.formData(); const keyId = data.get('keyId')?.toString(); diff --git a/src/routes/(portal)/portal/api-keys/create/+page.server.ts b/src/routes/(portal)/portal/api-keys/create/+page.server.ts index a283acd..645ef5c 100644 --- a/src/routes/(portal)/portal/api-keys/create/+page.server.ts +++ b/src/routes/(portal)/portal/api-keys/create/+page.server.ts @@ -9,7 +9,7 @@ export function load() { export const actions: Actions = { default: async ({ request, locals }) => { - const orgId = locals.session?.orgId; + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; if (!orgId) error(401, 'Not authenticated'); const data = await request.formData(); diff --git a/src/routes/(portal)/portal/dns/+page.server.ts b/src/routes/(portal)/portal/dns/+page.server.ts index 54ba223..737df65 100644 --- a/src/routes/(portal)/portal/dns/+page.server.ts +++ b/src/routes/(portal)/portal/dns/+page.server.ts @@ -15,7 +15,7 @@ export const load: PageServerLoad = async ({ parent }) => { export const actions: Actions = { verify: async ({ locals }) => { - const orgId = locals.session?.orgId; + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; if (!orgId) error(401, 'Not authenticated'); const result = await verifyDns(orgId); return result; diff --git a/src/routes/(portal)/portal/email-log/+page.server.ts b/src/routes/(portal)/portal/email-log/+page.server.ts index 2a2cee3..b323406 100644 --- a/src/routes/(portal)/portal/email-log/+page.server.ts +++ b/src/routes/(portal)/portal/email-log/+page.server.ts @@ -15,7 +15,7 @@ export const load: PageServerLoad = async ({ parent, url }) => { export const actions: Actions = { revoke: async ({ request, locals }) => { - const orgId = locals.session?.orgId; + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; if (!orgId) error(401, 'Not authenticated'); const data = await request.formData(); const emailId = data.get('emailId')?.toString(); diff --git a/src/routes/(portal)/portal/members/+page.server.ts b/src/routes/(portal)/portal/members/+page.server.ts new file mode 100644 index 0000000..f52d903 --- /dev/null +++ b/src/routes/(portal)/portal/members/+page.server.ts @@ -0,0 +1,82 @@ +import type { Actions, PageServerLoad } from './$types'; +import { error, fail } from '@sveltejs/kit'; +import { isEnabled } from '$lib/feature-flags'; +import { listOrgUsers, addUser, removeUser, setContactPerson } from '$lib/server/services/users'; + +export const load: PageServerLoad = async ({ parent }) => { + if (!isEnabled('portalMembers')) error(404, 'Not found'); + const { organization } = await parent(); + const members = await listOrgUsers(organization.id); + return { members }; +}; + +export const actions: Actions = { + add: async ({ request, locals }) => { + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; + if (!orgId) error(401, 'Not authenticated'); + + const data = await request.formData(); + const email = data.get('email')?.toString().trim().toLowerCase(); + const fullName = data.get('fullName')?.toString().trim(); + const phone = data.get('phone')?.toString().trim() || null; + + if (!email || !fullName) { + return fail(400, { error: 'Email and full name are required' }); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return fail(400, { error: 'Invalid email address' }); + } + + try { + await addUser(orgId, email, fullName, phone); + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('unique')) { + return fail(409, { error: 'A user with this email already exists' }); + } + throw err; + } + + return { added: true }; + }, + + remove: async ({ request, locals }) => { + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; + if (!orgId) error(401, 'Not authenticated'); + + const data = await request.formData(); + const userId = data.get('userId')?.toString(); + if (!userId) return fail(400, { error: 'Missing user ID' }); + + try { + await removeUser(userId, orgId); + } catch (err: unknown) { + if (err instanceof Error) { + return fail(400, { error: err.message }); + } + throw err; + } + + return { removed: true }; + }, + + setContact: async ({ request, locals }) => { + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; + if (!orgId) error(401, 'Not authenticated'); + + const data = await request.formData(); + const userId = data.get('userId')?.toString(); + if (!userId) return fail(400, { error: 'Missing user ID' }); + + try { + await setContactPerson(orgId, userId); + } catch (err: unknown) { + if (err instanceof Error) { + return fail(400, { error: err.message }); + } + throw err; + } + + return { contactChanged: true }; + } +}; diff --git a/src/routes/(portal)/portal/members/+page.svelte b/src/routes/(portal)/portal/members/+page.svelte new file mode 100644 index 0000000..3e4f6d7 --- /dev/null +++ b/src/routes/(portal)/portal/members/+page.svelte @@ -0,0 +1,327 @@ + + + + + + +{#if form?.error} + +{/if} + +{#if form?.added} +
+ + Member added successfully. +
+{/if} + +{#if form?.contactChanged} +
+ + Contact person updated. +
+{/if} + +{#if showAddForm} +
+

Add a new member

+
{ + return async ({ update, result }) => { + if (result.type === 'success') showAddForm = false; + await update(); + }; + }}> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+{/if} + +{#if data.members.length === 0} +
+ +

No members yet

+

Add the first member to your organization.

+
+{:else} +
+ + + + + + + + + + + + {#each data.members as member} + + + + + + + + {/each} + +
NameEmailPhoneRole
{member.fullName}{member.email}{member.phone ?? '—'} + {#if member.id === data.organization.contactUserId} + Contact person + {:else} + Member + {/if} + + {#if member.id !== data.organization.contactUserId} +
+ + +
+ {/if} + {#if confirmRemove === member.id} +
+ +
+ + +
+
+ {:else if member.id !== data.organization.contactUserId && member.id !== data.user?.id} + + {/if} +
+
+{/if} + + diff --git a/src/routes/(portal)/portal/organization/+page.server.ts b/src/routes/(portal)/portal/organization/+page.server.ts index e2d4fa9..e4e02fa 100644 --- a/src/routes/(portal)/portal/organization/+page.server.ts +++ b/src/routes/(portal)/portal/organization/+page.server.ts @@ -12,7 +12,7 @@ export const load: PageServerLoad = async ({ parent }) => { export const actions: Actions = { requestChange: async ({ request, locals }) => { - const orgId = locals.session?.orgId; + const orgId = locals.session?.impersonatingOrgId ?? locals.session?.orgId; if (!orgId) error(401, 'Not authenticated'); const data = await request.formData(); const fieldName = data.get('fieldName')?.toString(); diff --git a/src/routes/(portal)/portal/organization/+page.svelte b/src/routes/(portal)/portal/organization/+page.svelte index e2feddb..f9b4034 100644 --- a/src/routes/(portal)/portal/organization/+page.svelte +++ b/src/routes/(portal)/portal/organization/+page.svelte @@ -10,13 +10,14 @@ const org = $derived(data.organization); + const contactPerson = $derived(data.contactPerson); + const fields = $derived([ - { key: 'name', label: 'Organization name', value: org.name }, - { key: 'domain', label: 'Domain', value: org.domain }, - { key: 'email', label: 'Contact email', value: org.email }, - { key: 'contactName', label: 'Contact person', value: org.contactName }, - { key: 'phone', label: 'Phone number', value: org.phone ?? '—' }, - { key: 'kvkNumber', label: 'KvK number', value: org.kvkNumber ?? '—' } + { key: 'name', label: 'Organization name', value: org.name, editable: true }, + { key: 'domain', label: 'Domain', value: org.domain, editable: true }, + { key: 'signingEmail', label: 'Signing email', value: org.signingEmail, editable: true }, + { key: 'kvkNumber', label: 'KvK number', value: org.kvkNumber ?? '—', editable: true }, + { key: 'contactPerson', label: 'Contact person', value: contactPerson ? `${contactPerson.fullName} (${contactPerson.email})` : '—', editable: false } ]); function startEdit(key: string, currentValue: string) { @@ -54,37 +55,39 @@ {field.label} {field.value}
- {#if editingField === field.key} -
{ - return async ({ update }) => { - cancelEdit(); - await update(); - }; - }}> - - -
- - - -
-
- {:else} - + {#if field.editable} + {#if editingField === field.key} +
{ + return async ({ update }) => { + cancelEdit(); + await update(); + }; + }}> + + +
+ + + +
+
+ {:else} + + {/if} {/if}
{/each} diff --git a/src/routes/api/auth/yivi/callback/+server.ts b/src/routes/api/auth/yivi/callback/+server.ts index 96d79dc..920aba1 100644 --- a/src/routes/api/auth/yivi/callback/+server.ts +++ b/src/routes/api/auth/yivi/callback/+server.ts @@ -1,11 +1,8 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { verifyYiviSession } from '$lib/server/auth/yivi'; -import { - createSession, - findOrgByEmail, - findAdminByAttributes -} from '$lib/server/auth/session'; +import { createSession, findAdminByAttributes } from '$lib/server/auth/session'; +import { findUserByEmail } from '$lib/server/services/users'; export const POST: RequestHandler = async ({ request, cookies }) => { const body = await request.json(); @@ -24,6 +21,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { error(401, 'Yivi verification failed'); } + let userId: string | null = null; let orgId: string | null = null; let adminId: string | null = null; @@ -31,14 +29,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (!result.attributes.email) { error(401, 'Email attribute not disclosed'); } - const org = await findOrgByEmail(result.attributes.email); - if (!org) { - error(403, 'No organization found for this email. Please register first.'); + const found = await findUserByEmail(result.attributes.email); + if (!found) { + error(403, 'No account found for this email. Please register first.'); } - if (org.status !== 'active') { + if (found.org.status !== 'active') { error(403, 'Your organization is not yet approved.'); } - orgId = org.id; + userId = found.user.id; + orgId = found.org.id; } else { if (!result.attributes.email || !result.attributes.fullName || !result.attributes.phone) { error(401, 'Required attributes not disclosed'); @@ -54,7 +53,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { adminId = admin.id; } - const sessionToken = await createSession(type, orgId, adminId, result.attributes); + const sessionToken = await createSession(type, userId, orgId, adminId, result.attributes); cookies.set('pg_session', sessionToken, { path: '/', diff --git a/src/routes/api/auth/yivi/verify/+server.ts b/src/routes/api/auth/yivi/verify/+server.ts new file mode 100644 index 0000000..590f868 --- /dev/null +++ b/src/routes/api/auth/yivi/verify/+server.ts @@ -0,0 +1,27 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { verifyYiviSession } from '$lib/server/auth/yivi'; + +/** + * Verify a Yivi disclosure and return the disclosed attributes. + * Used by the registration flow — no session is created. + */ +export const POST: RequestHandler = async ({ request }) => { + const { irmaSessionToken } = (await request.json()) as { irmaSessionToken: string }; + + if (!irmaSessionToken) { + error(400, 'Missing irmaSessionToken'); + } + + const result = await verifyYiviSession(irmaSessionToken); + + if (!result.valid) { + error(401, 'Yivi verification failed'); + } + + if (!result.attributes.email || !result.attributes.fullName) { + error(401, 'Required attributes (email, full name) not disclosed'); + } + + return json({ attributes: result.attributes }); +}; diff --git a/tests/unit/types.test.ts b/tests/unit/types.test.ts index 569536d..a77fc0f 100644 --- a/tests/unit/types.test.ts +++ b/tests/unit/types.test.ts @@ -16,10 +16,9 @@ describe('type definitions', () => { id: 'test-uuid', name: 'Test Org', domain: 'test.com', - email: 'admin@test.com', - contactName: 'John Doe', - phone: '+31612345678', + signingEmail: 'admin@test.com', kvkNumber: '12345678', + contactUserId: null, status: 'active', createdAt: new Date(), updatedAt: new Date()