From 300a89c5b9f6b10b0caa9cd17373a37a8c41ce30 Mon Sep 17 00:00:00 2001 From: Conrad Bekondo Date: Mon, 13 Jan 2025 01:45:50 +0100 Subject: [PATCH] feat: refresh token support implemented --- db/migrations/0021_nifty_manta.sql | 29 + db/migrations/0022_famous_ozymandias.sql | 5 + db/migrations/meta/0021_snapshot.json | 1395 ++++++++++++++++ db/migrations/meta/0022_snapshot.json | 1449 +++++++++++++++++ db/migrations/meta/_journal.json | 14 + db/schema/users.ts | 73 +- lib/models/user.ts | 23 +- logging/common.ts | 18 +- server/functions/auth/index.mts | 4 +- server/handlers/auth.mts | 224 ++- server/helpers/auth.mts | 4 +- server/middleware/auth.mts | 62 +- server/schemas/user.mts | 22 +- .../interceptors/access-token.interceptor.ts | 26 +- .../auth-callback/auth-callback.component.ts | 16 +- src/app/state/user/actions.ts | 8 +- src/app/state/user/index.ts | 83 +- 17 files changed, 3361 insertions(+), 94 deletions(-) create mode 100644 db/migrations/0021_nifty_manta.sql create mode 100644 db/migrations/0022_famous_ozymandias.sql create mode 100644 db/migrations/meta/0021_snapshot.json create mode 100644 db/migrations/meta/0022_snapshot.json diff --git a/db/migrations/0021_nifty_manta.sql b/db/migrations/0021_nifty_manta.sql new file mode 100644 index 0000000..e2837d3 --- /dev/null +++ b/db/migrations/0021_nifty_manta.sql @@ -0,0 +1,29 @@ +CREATE TABLE "access_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "ip" varchar(39) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "revoked_at" timestamp, + "window" interval NOT NULL, + "replaced_by" uuid +); +--> statement-breakpoint +CREATE TABLE "refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "token" varchar(32) NOT NULL, + "user" bigint NOT NULL, + "ip" varchar(39) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "replaced_by" uuid, + "revoked_by" bigint, + "window" interval NOT NULL, + "access_token" uuid NOT NULL, + CONSTRAINT "refresh_tokens_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_replaced_by_access_tokens_id_fk" FOREIGN KEY ("replaced_by") REFERENCES "public"."access_tokens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_users_id_fk" FOREIGN KEY ("user") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_replaced_by_refresh_tokens_id_fk" FOREIGN KEY ("replaced_by") REFERENCES "public"."refresh_tokens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_revoked_by_users_id_fk" FOREIGN KEY ("revoked_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_access_token_access_tokens_id_fk" FOREIGN KEY ("access_token") REFERENCES "public"."access_tokens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "refresh_tokens_token_user_index" ON "refresh_tokens" USING btree ("token","user");--> statement-breakpoint +CREATE VIEW "public"."vw_refresh_tokens" AS (select (created_at + "window")::TIMESTAMP as "expires", "revoked_by", "replaced_by", "created_at", "access_token", "ip", "user", "token", "id" from "refresh_tokens"); \ No newline at end of file diff --git a/db/migrations/0022_famous_ozymandias.sql b/db/migrations/0022_famous_ozymandias.sql new file mode 100644 index 0000000..a46ed40 --- /dev/null +++ b/db/migrations/0022_famous_ozymandias.sql @@ -0,0 +1,5 @@ +DROP VIEW "public"."vw_refresh_tokens";--> statement-breakpoint +ALTER TABLE "access_tokens" ADD COLUMN "user" bigint NOT NULL;--> statement-breakpoint +ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_user_users_id_fk" FOREIGN KEY ("user") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE VIEW "public"."vw_access_tokens" AS (select "user", (now() > (created_at + "window")::TIMESTAMP)::BOOLEAN OR replaced_by IS NOT NULL as "is_expired", (created_at + "window")::TIMESTAMP as "expires_at", "created_at", "ip", "id" from "access_tokens");--> statement-breakpoint +CREATE VIEW "public"."vw_refresh_tokens" AS (select (now()::TIMESTAMP > (created_at + "window")::TIMESTAMP)::BOOLEAN as "is_expired", (created_at + "window")::TIMESTAMP as "expires", "revoked_by", "replaced_by", "created_at", "access_token", "ip", "user", "token", "id" from "refresh_tokens"); \ No newline at end of file diff --git a/db/migrations/meta/0021_snapshot.json b/db/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000..87c7691 --- /dev/null +++ b/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,1395 @@ +{ + "id": "0d07417a-a1b5-416b-b3b5-09c8d55ed073", + "prevId": "ed875421-5fff-4903-b19d-8a5143a8eae7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.campaign_publications": { + "name": "campaign_publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaign_publications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "campaign": { + "name": "campaign", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "publish_after": { + "name": "publish_after", + "type": "date", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "publish_before": { + "name": "publish_before", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_publications_campaign_campaigns_id_fk": { + "name": "campaign_publications_campaign_campaigns_id_fk", + "tableFrom": "campaign_publications", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaigns_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "emails": { + "name": "emails", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "phones": { + "name": "phones", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "categories": { + "name": "categories", + "type": "bigint[]", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_created_by_users_id_fk": { + "name": "campaigns_created_by_users_id_fk", + "tableFrom": "campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payment_method_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_methods_provider_owner_index": { + "name": "payment_methods_provider_owner_index", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_owner_users_id_fk": { + "name": "payment_methods_owner_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_transactions": { + "name": "payment_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_transaction_id": { + "name": "external_transaction_id", + "type": "varchar(400)", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inbound": { + "name": "inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transactions": { + "name": "wallet_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from": { + "name": "from", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_transaction": { + "name": "account_transaction", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_transactions_from_wallets_id_fk": { + "name": "wallet_transactions_from_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_to_wallets_id_fk": { + "name": "wallet_transactions_to_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_account_transaction_payment_transactions_id_fk": { + "name": "wallet_transactions_account_transaction_payment_transactions_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "payment_transactions", + "columnsFrom": [ + "account_transaction" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owned_by": { + "name": "owned_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "starting_balance": { + "name": "starting_balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_owned_by_users_id_fk": { + "name": "wallets_owned_by_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "owned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_replaced_by_access_tokens_id_fk": { + "name": "access_tokens_replaced_by_access_tokens_id_fk", + "tableFrom": "access_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_connections": { + "name": "account_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_connection_providers", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_connections_user_users_id_fk": { + "name": "account_connections_user_users_id_fk", + "tableFrom": "account_connections", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federated_credentials": { + "name": "federated_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_access_token": { + "name": "last_access_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_tokens_token_user_index": { + "name": "refresh_tokens_token_user_index", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_users_id_fk": { + "name": "refresh_tokens_user_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_replaced_by_refresh_tokens_id_fk": { + "name": "refresh_tokens_replaced_by_refresh_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "refresh_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_revoked_by_users_id_fk": { + "name": "refresh_tokens_revoked_by_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "revoked_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_access_token_access_tokens_id_fk": { + "name": "refresh_tokens_access_token_access_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "access_token" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_users_id_fk": { + "name": "user_prefs_user_users_id_fk", + "tableFrom": "user_prefs", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "100", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "names": { + "name": "names", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "dob": { + "name": "dob", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_credentials_federated_credentials_id_fk": { + "name": "users_credentials_federated_credentials_id_fk", + "tableFrom": "users", + "tableTo": "federated_credentials", + "columnsFrom": [ + "credentials" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_codes": { + "name": "verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_codes_hash_unique": { + "name": "verification_codes_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_method_provider": { + "name": "payment_method_provider", + "schema": "public", + "values": [ + "momo" + ] + }, + "public.payment_method_status": { + "name": "payment_method_status", + "schema": "public", + "values": [ + "active", + "inactive", + "re-connection required" + ] + }, + "public.transaction_status": { + "name": "transaction_status", + "schema": "public", + "values": [ + "pending", + "cancelled", + "complete" + ] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "funding", + "reward", + "withdrawal" + ] + }, + "public.account_connection_providers": { + "name": "account_connection_providers", + "schema": "public", + "values": [ + "telegram" + ] + }, + "public.account_connection_status": { + "name": "account_connection_status", + "schema": "public", + "values": [ + "active", + "inactive", + "reconnect_required" + ] + }, + "public.theme_pref": { + "name": "theme_pref", + "schema": "public", + "values": [ + "system", + "dark", + "light" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.vw_funding_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_funding_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_reward_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_reward_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_verification_codes": { + "columns": { + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"hash\", \"created_at\", \n (\"created_at\" + \"window\")::TIMESTAMP\n as \"expires_at\", \n (CASE\n WHEN \"confirmed_at\" IS NOT NULL THEN true\n ELSE NOW() > (\"created_at\" + \"window\")\n END)::BOOlEAN\n as \"is_expired\", \"data\" from \"verification_codes\"", + "name": "vw_verification_codes", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_refresh_tokens": { + "columns": { + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select (created_at + \"window\")::TIMESTAMP as \"expires\", \"revoked_by\", \"replaced_by\", \"created_at\", \"access_token\", \"ip\", \"user\", \"token\", \"id\" from \"refresh_tokens\"", + "name": "vw_refresh_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/0022_snapshot.json b/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..90ea0ce --- /dev/null +++ b/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,1449 @@ +{ + "id": "66e6117c-0980-456e-9261-f1f2d3b1701a", + "prevId": "0d07417a-a1b5-416b-b3b5-09c8d55ed073", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.campaign_publications": { + "name": "campaign_publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaign_publications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "campaign": { + "name": "campaign", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "publish_after": { + "name": "publish_after", + "type": "date", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "publish_before": { + "name": "publish_before", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_publications_campaign_campaigns_id_fk": { + "name": "campaign_publications_campaign_campaigns_id_fk", + "tableFrom": "campaign_publications", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaigns_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "emails": { + "name": "emails", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "phones": { + "name": "phones", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "categories": { + "name": "categories", + "type": "bigint[]", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_created_by_users_id_fk": { + "name": "campaigns_created_by_users_id_fk", + "tableFrom": "campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payment_method_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_methods_provider_owner_index": { + "name": "payment_methods_provider_owner_index", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_owner_users_id_fk": { + "name": "payment_methods_owner_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_transactions": { + "name": "payment_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_transaction_id": { + "name": "external_transaction_id", + "type": "varchar(400)", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inbound": { + "name": "inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transactions": { + "name": "wallet_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from": { + "name": "from", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_transaction": { + "name": "account_transaction", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_transactions_from_wallets_id_fk": { + "name": "wallet_transactions_from_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_to_wallets_id_fk": { + "name": "wallet_transactions_to_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_account_transaction_payment_transactions_id_fk": { + "name": "wallet_transactions_account_transaction_payment_transactions_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "payment_transactions", + "columnsFrom": [ + "account_transaction" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owned_by": { + "name": "owned_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "starting_balance": { + "name": "starting_balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_owned_by_users_id_fk": { + "name": "wallets_owned_by_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "owned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_user_users_id_fk": { + "name": "access_tokens_user_users_id_fk", + "tableFrom": "access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_replaced_by_access_tokens_id_fk": { + "name": "access_tokens_replaced_by_access_tokens_id_fk", + "tableFrom": "access_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_connections": { + "name": "account_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_connection_providers", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_connections_user_users_id_fk": { + "name": "account_connections_user_users_id_fk", + "tableFrom": "account_connections", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federated_credentials": { + "name": "federated_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_access_token": { + "name": "last_access_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_tokens_token_user_index": { + "name": "refresh_tokens_token_user_index", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_users_id_fk": { + "name": "refresh_tokens_user_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_replaced_by_refresh_tokens_id_fk": { + "name": "refresh_tokens_replaced_by_refresh_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "refresh_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_revoked_by_users_id_fk": { + "name": "refresh_tokens_revoked_by_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "revoked_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_access_token_access_tokens_id_fk": { + "name": "refresh_tokens_access_token_access_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "access_token" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_users_id_fk": { + "name": "user_prefs_user_users_id_fk", + "tableFrom": "user_prefs", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "100", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "names": { + "name": "names", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "dob": { + "name": "dob", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_credentials_federated_credentials_id_fk": { + "name": "users_credentials_federated_credentials_id_fk", + "tableFrom": "users", + "tableTo": "federated_credentials", + "columnsFrom": [ + "credentials" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_codes": { + "name": "verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_codes_hash_unique": { + "name": "verification_codes_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_method_provider": { + "name": "payment_method_provider", + "schema": "public", + "values": [ + "momo" + ] + }, + "public.payment_method_status": { + "name": "payment_method_status", + "schema": "public", + "values": [ + "active", + "inactive", + "re-connection required" + ] + }, + "public.transaction_status": { + "name": "transaction_status", + "schema": "public", + "values": [ + "pending", + "cancelled", + "complete" + ] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "funding", + "reward", + "withdrawal" + ] + }, + "public.account_connection_providers": { + "name": "account_connection_providers", + "schema": "public", + "values": [ + "telegram" + ] + }, + "public.account_connection_status": { + "name": "account_connection_status", + "schema": "public", + "values": [ + "active", + "inactive", + "reconnect_required" + ] + }, + "public.theme_pref": { + "name": "theme_pref", + "schema": "public", + "values": [ + "system", + "dark", + "light" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.vw_funding_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_funding_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_reward_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_reward_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_verification_codes": { + "columns": { + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"hash\", \"created_at\", \n (\"created_at\" + \"window\")::TIMESTAMP\n as \"expires_at\", \n (CASE\n WHEN \"confirmed_at\" IS NOT NULL THEN true\n ELSE NOW() > (\"created_at\" + \"window\")\n END)::BOOlEAN\n as \"is_expired\", \"data\" from \"verification_codes\"", + "name": "vw_verification_codes", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_access_tokens": { + "columns": { + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select \"user\", (now() > (created_at + \"window\")::TIMESTAMP)::BOOLEAN OR replaced_by IS NOT NULL as \"is_expired\", (created_at + \"window\")::TIMESTAMP as \"expires_at\", \"created_at\", \"ip\", \"id\" from \"access_tokens\"", + "name": "vw_access_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_refresh_tokens": { + "columns": { + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select (now()::TIMESTAMP > (created_at + \"window\")::TIMESTAMP)::BOOLEAN as \"is_expired\", (created_at + \"window\")::TIMESTAMP as \"expires\", \"revoked_by\", \"replaced_by\", \"created_at\", \"access_token\", \"ip\", \"user\", \"token\", \"id\" from \"refresh_tokens\"", + "name": "vw_refresh_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index da28f99..3100c94 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1736391217180, "tag": "0020_steep_azazel", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1736618004176, + "tag": "0021_nifty_manta", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1736694971000, + "tag": "0022_famous_ozymandias", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema/users.ts b/db/schema/users.ts index f1759fe..0bfcf82 100644 --- a/db/schema/users.ts +++ b/db/schema/users.ts @@ -1,8 +1,73 @@ import { sql } from 'drizzle-orm'; -import { bigint, date, interval, jsonb, pgEnum, pgTable, pgView, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; -import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'; +import { + AnyPgColumn, + bigint, + date, + interval, + jsonb, + pgEnum, + pgTable, + pgView, + timestamp, + uniqueIndex, + uuid, + varchar +} from 'drizzle-orm/pg-core'; +import { createSelectSchema, createUpdateSchema } from 'drizzle-zod'; import { z } from 'zod'; +export const accessTokens = pgTable('access_tokens', { + id: uuid().defaultRandom().primaryKey(), + ip: varchar({ length: 39 }).notNull(), + created_at: timestamp({ mode: 'date' }).notNull().defaultNow(), + revoked_at: timestamp({ mode: 'date' }), + window: interval().notNull(), + user: bigint({ mode: 'number' }).notNull().references(() => users.id, { onDelete: 'cascade' }), + replacedBy: uuid().references((): AnyPgColumn => accessTokens.id) +}); + +export const vwAccessTokens = pgView('vw_access_tokens').as(qb => { + return qb.select({ + user: accessTokens.user, + is_expired: sql.raw(`(now() > (created_at + "window")::TIMESTAMP)::BOOLEAN OR replaced_by IS NOT NULL`).as('is_expired'), + expires_at: sql.raw(`(created_at + "window")::TIMESTAMP`).as('expires_at'), + created_at: accessTokens.created_at, + ip: accessTokens.ip, + id: accessTokens.id + }).from(accessTokens) +}) + +export const refreshTokens = pgTable('refresh_tokens', { + id: uuid().defaultRandom().primaryKey(), + token: varchar({ length: 32 }).notNull().unique(), + user: bigint({ mode: 'number' }).notNull().references(() => users.id, { onDelete: 'cascade' }), + ip: varchar({ length: 39 }).notNull(), + created_at: timestamp({ mode: 'date' }).notNull().defaultNow(), + replaced_by: uuid().references((): AnyPgColumn => refreshTokens.id), + revoked_by: bigint({ mode: 'number' }).references(() => users.id), + window: interval().notNull(), + access_token: uuid().notNull().references(() => accessTokens.id) +}, table => { + return { + index: uniqueIndex().on(table.token, table.user) + } +}); + +export const vwRefreshTokens = pgView('vw_refresh_tokens').as(qb => { + return qb.select({ + isExpired: sql.raw(`(now()::TIMESTAMP > (created_at + "${refreshTokens.window.name}")::TIMESTAMP)::BOOLEAN`).as('is_expired'), + expires: sql.raw(`(created_at + "${refreshTokens.window.name}")::TIMESTAMP`).as('expires'), + revoked_by: refreshTokens.revoked_by, + replaced_by: refreshTokens.replaced_by, + created_at: refreshTokens.created_at, + access_token: refreshTokens.access_token, + ip: refreshTokens.ip, + user: refreshTokens.user, + token: refreshTokens.token, + id: refreshTokens.id + }).from(refreshTokens) +}) + export const verificationCodes = pgTable('verification_codes', { id: uuid().primaryKey().defaultRandom(), created_at: timestamp({ mode: 'date' }).notNull().defaultNow(), @@ -12,8 +77,6 @@ export const verificationCodes = pgTable('verification_codes', { data: jsonb() }); -export const newVerificationSchema = createInsertSchema(verificationCodes); - export const verificationCodesView = pgView('vw_verification_codes').as(qb => { return qb.select({ code: verificationCodes.hash, @@ -83,6 +146,6 @@ export const updatePrefSchema = createUpdateSchema(userPrefs).pick({ language: true }); -export const userSchema = createSelectSchema(users); +export const UserSchema = createSelectSchema(users); const connectionsSchema = createSelectSchema(accountConnections); export type AccountConnection = z.infer; diff --git a/lib/models/user.ts b/lib/models/user.ts index c398eed..781d8f1 100644 --- a/lib/models/user.ts +++ b/lib/models/user.ts @@ -1,3 +1,24 @@ +import { z } from "zod"; + +export const RefreshTokenClaimsSchema = z.object({ + value: z.string(), + tokenId: z.string().uuid() +}); + +export type RefreshTokenClaims = z.infer; + +export const AccessTokenClaimsSchema = z.object({ + email: z.string().email(), + sub: z.number(), + name: z.string(), + image: z.string().optional(), + tokenId: z.string().uuid(), + aud: z.string(), + exp: z.number() +}); + +export type AccessTokenClaims = z.infer; + export type UserPrefs = { theme: 'dark' | 'light' | 'system'; country: string; @@ -8,7 +29,7 @@ export type UserPrefs = { updatedAt: Date | null; user: number; } -export type DisplayPrefs = Pick; +export type DisplayPrefs = Pick; export type ConnectedAccount = { id: string diff --git a/logging/common.ts b/logging/common.ts index 7d4ab6b..447d0b9 100644 --- a/logging/common.ts +++ b/logging/common.ts @@ -1,8 +1,8 @@ -import winston from 'winston'; -import Transport from 'winston-transport'; -import { Logtail } from '@logtail/node'; +import winston from 'winston'; +import Transport from 'winston-transport'; +import { Logtail } from '@logtail/node'; import { from, retry } from 'rxjs'; -import { hostname } from 'node:os'; +import { hostname } from 'node:os'; class LogTailTransport extends Transport { private logTail: Logtail; @@ -12,9 +12,12 @@ class LogTailTransport extends Transport { this.logTail = new Logtail(String(process.env['LOGTAIL_TOKEN'])); } - override log(info: { message: string, level: string} & Record, next: () => void): any { + override log(info: { message: string, level: string } & Record, next: () => void): any { const { message, level } = info; - const rest = Object.entries(info).filter(([k]) => k != 'mesage' && k != 'level').reduce((acc, [k,v]) =>({...acc, [k]: v}), {} as Record); + const rest = Object.entries(info).filter(([k]) => k != 'mesage' && k != 'level').reduce((acc, [k, v]) => ({ + ...acc, + [k]: v + }), {} as Record); from(this.logTail.log(message, level, rest)).pipe(retry(20)).subscribe(); next(); @@ -25,6 +28,7 @@ export const DevelopmentFormatter = winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: 'DD-MM-YYYY HH:mm:ss' }), winston.format.errors({ stack: true }), + winston.format.align(), winston.format.printf(({ level, message, timestamp, stack }) => `${timestamp} ${level}: ${stack ?? message}`) ); export const ProductionFormatter = winston.format.json(); @@ -56,7 +60,9 @@ const defaultMeta = { }; const defaultLogger = process.env['NODE_ENV'] == 'development' ? useDevLogger() : useProdLogger(); + export function useLogger(meta: Record) { return defaultLogger.child(meta); } + export default defaultLogger; diff --git a/server/functions/auth/index.mts b/server/functions/auth/index.mts index 7baf21b..41aef7d 100644 --- a/server/functions/auth/index.mts +++ b/server/functions/auth/index.mts @@ -1,4 +1,4 @@ -import { handleGoogleOauthCallback, handleGoogleSignIn, handleUserSignIn, removeUserAccount } from '@handlers/auth.mjs'; +import { handleGoogleOauthCallback, handleGoogleSignIn, handleRevokeAccessToken, handleTokenRefresh, handleUserSignIn, removeUserAccount } from '@handlers/auth.mjs'; import { prepareHandler } from '@helpers/handler.mjs'; import { jwtAuth } from '@middleware/auth.mjs'; import { Router } from 'express'; @@ -8,5 +8,7 @@ const router = Router(); router.get('/google', handleGoogleSignIn); router.get('/google/callback', handleGoogleOauthCallback({ failureRedirect: '/auth/login' }), handleUserSignIn); router.delete('/', jwtAuth, removeUserAccount); +router.get('/refresh', handleTokenRefresh); +router.get('/revoke-token', jwtAuth, handleRevokeAccessToken); export const handler = prepareHandler('auth', router); diff --git a/server/handlers/auth.mts b/server/handlers/auth.mts index 93d8da0..d6f4460 100644 --- a/server/handlers/auth.mts +++ b/server/handlers/auth.mts @@ -1,19 +1,22 @@ import { extractUser } from '@helpers/auth.mjs'; import { useUsersDb } from '@helpers/db.mjs'; import { handleError } from '@helpers/error.mjs'; +import { AccessTokenClaims } from '@lib/models/user'; import { useLogger } from '@logger/common'; import * as users from '@schemas/users'; -import { userSchema } from '@schemas/users'; -import { eq } from 'drizzle-orm'; +import { UserSchema } from '@schemas/users'; +import { RefreshTokenValidationSchema } from '@zod-schemas/user.mjs'; +import { and, eq, isNull } from 'drizzle-orm'; import { Request, Response } from 'express'; import { sign } from 'jsonwebtoken'; +import { randomBytes } from 'node:crypto'; import passport from 'passport'; import { Strategy as GoogleStrategy, Profile, VerifyCallback } from 'passport-google-oauth20'; import { doRemovePaymentMethods } from './payment.mjs'; import { doRemoveAccountConnections } from './user.mjs'; import { doCreateUserWallet, doDeleteUserWallet } from './wallet.mjs'; -const logger = useLogger({ handler: 'auth' }); +const logger = useLogger({ service: 'auth' }); passport.use(new GoogleStrategy({ clientID: String(process.env['OAUTH2_CLIENT_ID']), clientSecret: String(process.env['OAUTH2_CLIENT_SECRET']), @@ -65,7 +68,7 @@ passport.use(new GoogleStrategy({ })); passport.serializeUser((user, done) => { - return done(null, userSchema.parse(user).id); + return done(null, UserSchema.parse(user).id); }); passport.deserializeUser((id, done) => { @@ -108,22 +111,205 @@ export function handleGoogleOauthCallback({ failureRedirect }: { failureRedirect return passport.authenticate('google', { failureRedirect, session: false }) } -export function handleUserSignIn(req: Request, res: Response) { +export async function handleUserSignIn(req: Request, res: Response) { logger.info('signing in user'); - if (!req.user) { - res.status(401).json({ message: 'Unauthorized' }); - return; + const ip = String(req.header('client-ip')); + + try { + const { success, data } = UserSchema.safeParse(req.user) + if (!success) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const user = data; + + logger.info('generating refresh token', { email: user.email }); + const refreshToken = randomBytes(16).toString('hex'); + const db = useUsersDb(); + const { accessTokenId, refreshTokenId } = await db.transaction(async t => { + const [{ accessTokenId }] = await t.insert(users.accessTokens).values({ + ip, + user: user.id, + window: String(process.env['JWT_LIFETIME']) + }).returning({ accessTokenId: users.accessTokens.id }); + + const [{ refreshTokenId }] = await t.insert(users.refreshTokens).values({ + window: String(process.env['REFRESH_TOKEN_LIFETIME']), + ip, + token: refreshToken, + user: user.id, + access_token: accessTokenId + }).returning({ refreshTokenId: users.refreshTokens.id }); + + return { accessTokenId, refreshTokenId }; + }); + + logger.info('generated token pair'); + + const signedAccessToken = sign({ + email: user.email, + sub: user.id, + name: user.names, + image: user.imageUrl, + aud: String(process.env['ORIGIN']), + tokenId: accessTokenId + }, String(process.env['JWT_SECRET']), { expiresIn: process.env['JWT_LIFETIME'] }) + + const signedRefreshToken = sign({ + value: refreshToken, + tokenId: refreshTokenId + }, String(process.env['JWT_SECRET']), { + expiresIn: process.env['REFRESH_TOKEN_LIFETIME'] + }); + + logger.info('Signed in user', { email: user.email }); + res.redirect(`/auth/oauth2/callback?access=${signedAccessToken}&refresh=${signedRefreshToken}`) + } catch (e) { + handleError(e as Error, res); + } +} + +export async function handleTokenRefresh(req: Request, res: Response) { + const ip = String(req.header('client-ip')); + try { + logger.info('refreshing tokens'); + const { success, data, error } = RefreshTokenValidationSchema.safeParse(req.query); + console.log(JSON.stringify(error)); + + if (!success) { + res.status(403).json({ message: 'Invalid refresh token' }); + return; + } + + const { token: refreshToken } = data; + + const db = useUsersDb(); + const existingRefreshTokenResult = await db.select().from(users.vwRefreshTokens).where( + and( + eq(users.vwRefreshTokens.id, refreshToken.tokenId), + eq(users.vwRefreshTokens.isExpired, false), + eq(users.vwRefreshTokens.token, refreshToken.value), + isNull(users.vwRefreshTokens.replaced_by), + isNull(users.vwRefreshTokens.revoked_by), + eq(users.vwRefreshTokens.ip, ip) + )) + .limit(1); + + if (existingRefreshTokenResult.length == 0) { + res.status(403).json({ message: 'Invalid refresh token' }); + return; + } + + const [existingRefreshToken] = existingRefreshTokenResult; + const existingAccessTokenResult = await db.select().from(users.accessTokens).where(eq(users.accessTokens.id, existingRefreshToken.access_token)); + + if (existingAccessTokenResult.length == 0) { + res.status(403).json({ message: 'Invalid refresh token' }); + return; + } + + const [existingAccessToken] = existingAccessTokenResult; + + const userResult = await db.select().from(users.users).where(eq(users.users.id, existingRefreshToken.user)); + if (userResult.length == 0) { + res.status(403).json({ message: 'Invalid refresh token' }); + return; + } + + const [user] = userResult; + logger.info('existing refresh token validated', { email: user.email }); + + const newRefreshToken = randomBytes(16).toString('hex'); + + logger.info('updating database'); + const { accessTokenId, refreshTokenId } = await db.transaction(async t => { + const [{ accessTokenId }] = await t.insert(users.accessTokens).values({ + ip, + user: user.id, + window: String(process.env['JWT_LIFETIME']) + }).returning({ accessTokenId: users.accessTokens.id }); + + await t.update(users.accessTokens).set({ + replacedBy: accessTokenId + }).where(eq(users.accessTokens.id, existingAccessToken.id)) + + const [{ refreshTokenId }] = await t.insert(users.refreshTokens).values({ + window: String(process.env['REFRESH_TOKEN_LIFETIME']), + ip, + token: newRefreshToken, + user: user.id, + access_token: accessTokenId + }).returning({ refreshTokenId: users.refreshTokens.id }); + + await t.update(users.refreshTokens).set({ + replaced_by: refreshTokenId + }).where(eq(users.refreshTokens.id, existingRefreshToken.id)); + + return { accessTokenId, refreshTokenId }; + }); + + const claims = { + email: user.email, + sub: user.id, + name: user.names, + image: user.imageUrl, + tokenId: accessTokenId, + aud: String(process.env['ORIGIN']) + } as AccessTokenClaims; + + logger.info('signing new access token for user', { email: user.email }); + const signedAccessToken = sign(claims, String(process.env['JWT_SECRET']), { expiresIn: process.env['JWT_LIFETIME'] ?? '15m' }); + + logger.info('signing replacement refresh token', { email: user.email }); + const signedRefreshToken = sign({ + value: newRefreshToken, + tokenId: refreshTokenId + }, String(process.env['JWT_SECRET']), { expiresIn: process.env['REFRESH_TOKEN_LIFETIME'] ?? '90d' }) + + res.json({ access: signedAccessToken, refresh: signedRefreshToken }); + } catch (e) { + handleError(e as Error, res); } +} + +export async function handleRevokeAccessToken(req: Request, res: Response) { + try { + const user = extractUser(req); + logger.info('Revoking tokens', { email: user.email }); + + const { success, data } = RefreshTokenValidationSchema.safeParse(req.query); + if (!success) { + res.status(403).json({ message: 'Invalid token' }); + return + } + + const { token: { tokenId, value: tokenValue } } = data; + const db = useUsersDb(); + const refreshTokenResult = await db.select().from(users.vwRefreshTokens).where( + and( + eq(users.vwRefreshTokens.id, tokenId), + eq(users.vwRefreshTokens.token, tokenValue), + eq(users.vwRefreshTokens.isExpired, false), + eq(users.vwRefreshTokens.user, user.id) + ) + ).limit(1); + + if (refreshTokenResult.length == 0) { + res.status(403).json({ message: 'Invalid tokens' }); + return; + } - const user = userSchema.parse(req.user); - const jwt = sign({ - email: user.email, - sub: user.id, - name: user.names, - image: user.imageUrl, - aud: String(process.env['ORIGIN']) - }, String(process.env['JWT_SECRET']), { expiresIn: process.env['JWT_LIFETIME'] ?? '1h' }); - - logger.info('signed in user', { email: user.email }); - return res.redirect(`/auth/oauth2/callback?access_token=${jwt}`); + const [refreshToken] = refreshTokenResult + logger.info('tokens valid. updating database'); + await db.transaction(async t => { + await t.update(users.accessTokens).set({ revoked_at: new Date() }).where(eq(users.accessTokens.id, refreshToken.access_token)); + await t.update(users.refreshTokens).set({ revoked_by: user.id }).where(eq(users.refreshTokens.id, refreshToken.id)); + }); + + logger.info('tokens revoked'); + res.status(204).json({}); + } catch (e) { + handleError(e as Error, res); + } } diff --git a/server/helpers/auth.mts b/server/helpers/auth.mts index 6e6c154..908c92d 100644 --- a/server/helpers/auth.mts +++ b/server/helpers/auth.mts @@ -1,7 +1,7 @@ -import { userSchema } from 'db/schema/users'; +import { UserSchema } from 'db/schema/users'; import { Request } from 'express'; export function extractUser(req: Request) { if (!req.user) throw new Error('User not found'); - return userSchema.parse(req.user); + return UserSchema.parse(req.user); } diff --git a/server/middleware/auth.mts b/server/middleware/auth.mts index cac5923..a2f5fbf 100644 --- a/server/middleware/auth.mts +++ b/server/middleware/auth.mts @@ -1,9 +1,12 @@ -import passport from 'passport'; +import { Context } from '@netlify/functions'; +import { vwAccessTokens } from '@schemas/users'; +import { AccessTokenValidationSchema } from '@zod-schemas/user.mjs'; import express, { NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import passport from 'passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { useUsersDb } from '../helpers/db.mjs'; -import { Context } from '@netlify/functions'; -import jwt from 'jsonwebtoken'; +import { and, eq } from 'drizzle-orm'; const { verify } = jwt; @@ -20,19 +23,33 @@ passport.use(new Strategy( { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: String(process.env['JWT_SECRET']), - audience: String(process.env['ORIGIN']) + audience: String(process.env['ORIGIN']), + passReqToCallback: true }, - async (payload, done) => { - const { sub } = payload; - const db = useUsersDb(); - const user = await db.query.users.findFirst({ - where: (users, { eq }) => eq(sub, users.id), - }); - if (!user) { - done(new Error('Account not found')); - return + async (req: express.Request, payload, done) => { + try { + const { sub, tokenId } = payload; + const ip = String(req.header('client-ip')); + const db = useUsersDb(); + const user = await db.query.users.findFirst({ + where: (users, { eq }) => eq(sub, users.id), + }); + const accessToken = await db.query.accessTokens.findFirst({ + where: (token, { eq, and, isNull }) => and(eq(token.id, tokenId), eq(token.ip, ip), isNull(token.revoked_at)) + }); + + if (!accessToken) { + done(new Error('Invalid access token'), null); + return; + } + if (!user) { + done(new Error('Account not found')); + return; + } + done(null, user); + } catch (e) { + done(e, null); } - done(null, user); } )); @@ -50,7 +67,22 @@ export const rawAuth = async (req: Request, ctx: Context, next: (req: Request, c if (scheme !== 'Bearer') return unauthorizedResponse; try { - verify(token, String(process.env['JWT_SECRET'])) + const { success, data } = AccessTokenValidationSchema.safeParse(token); + if (!success) { + return unauthorizedResponse; + } + + const db = useUsersDb(); + const result = await db.select().from(vwAccessTokens).where(and( + eq(vwAccessTokens.user, data.sub), + eq(vwAccessTokens.ip, ctx.ip), + eq(vwAccessTokens.is_expired, false), + eq(vwAccessTokens.id, data.tokenId) + )).limit(1); + + if (result.length == 0) { + return unauthorizedResponse; + } } catch (e) { console.error(e); return unauthorizedResponse; diff --git a/server/schemas/user.mts b/server/schemas/user.mts index 4240841..c207bb2 100644 --- a/server/schemas/user.mts +++ b/server/schemas/user.mts @@ -1,6 +1,22 @@ -import { hashThese } from "../util"; -import { z } from "zod"; +import { hashThese } from '../util'; +import { z } from 'zod'; +import jwt from 'jsonwebtoken'; +import { AccessTokenClaimsSchema, RefreshTokenClaimsSchema } from '@lib/models/user'; + +const { verify } = jwt; export const CodeVerificationSchema = z.object({ code: z.string().length(6).transform(arg => hashThese(arg)) -}) +}); + +export const RefreshTokenValidationSchema = z.object({ + token: z.string() + .transform(t => verify(t, String(process.env['JWT_SECRET']))) + .pipe(RefreshTokenClaimsSchema).transform(({ value, tokenId }) => ({ value, tokenId })) +}); + +export const AccessTokenValidationSchema = z.string() + .refine(t => verify(t, String(process.env['JWT_SECRET']))) + .pipe( + AccessTokenClaimsSchema + ); diff --git a/src/app/interceptors/access-token.interceptor.ts b/src/app/interceptors/access-token.interceptor.ts index dbef33c..e7c9716 100644 --- a/src/app/interceptors/access-token.interceptor.ts +++ b/src/app/interceptors/access-token.interceptor.ts @@ -1,14 +1,30 @@ -import { HttpInterceptorFn } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { Store } from '@ngxs/store'; -import { accessToken } from '../state/user'; +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Navigate } from '@ngxs/router-plugin'; +import { Store } from '@ngxs/store'; +import { catchError, concatMap, EMPTY, throwError } from 'rxjs'; +import { accessToken, RefreshAccessToken } from '../state/user'; export const accessTokenInterceptor: HttpInterceptorFn = (req, next) => { const store = inject(Store); const token = store.selectSnapshot(accessToken); if (req.url.startsWith('/api') && token) { - return next(req.clone({ setHeaders: { 'Authorization': `Bearer ${token}` } })); + return next(req.clone({ setHeaders: { 'Authorization': `Bearer ${token}` } })).pipe( + catchError((e: HttpErrorResponse) => { + if (e.status != 401 && req.url == '/api/auth/revoke-token') return throwError(() => e); + return store.dispatch(RefreshAccessToken).pipe( + concatMap(() => next(req.clone({ setHeaders: { authorization: `Bearer ${store.selectSnapshot(accessToken)}` } }))), + catchError((e: HttpErrorResponse) => { + if (e.status == 403) { + store.dispatch(new Navigate(['/auth/login'], undefined, { queryParamsHandling: 'preserve' })) + return EMPTY; + } + return throwError(() => e); + }) + ); + }) + ); } return next(req); }; diff --git a/src/app/pages/auth-callback/auth-callback.component.ts b/src/app/pages/auth-callback/auth-callback.component.ts index a84ad61..baab341 100644 --- a/src/app/pages/auth-callback/auth-callback.component.ts +++ b/src/app/pages/auth-callback/auth-callback.component.ts @@ -1,9 +1,9 @@ import { Component, effect, signal } from '@angular/core'; -import { injectQueryParams } from 'ngxtension/inject-query-params'; -import { dispatch } from '@ngxs/store'; import { Navigate } from '@ngxs/router-plugin'; +import { dispatch } from '@ngxs/store'; +import { injectQueryParams } from 'ngxtension/inject-query-params'; import { switchMap, timer } from 'rxjs'; -import { FinishGoogleSignInFlow } from '../../state/user'; +import { FinishGoogleSignInFlow, ParamsSchema } from '../../state/user'; @Component({ selector: 'tm-auth-callback', @@ -20,22 +20,24 @@ import { FinishGoogleSignInFlow } from '../../state/user'; styleUrl: './auth-callback.component.scss' }) export class AuthCallbackComponent { - private accessToken = injectQueryParams('access_token'); + private params = injectQueryParams(); readonly failed = signal(false); private navigate = dispatch(Navigate); private finishFlow = dispatch(FinishGoogleSignInFlow); constructor() { effect(() => { - const token = this.accessToken(); - if (!token) { + const params = this.params(); + const { success, data } = ParamsSchema.safeParse(params); + if (!success) { this.failed.set(true); timer(5000).pipe( switchMap(() => this.navigate(['/auth/login'], undefined, { queryParamsHandling: 'preserve' })) ).subscribe() } else { + const { access, refresh } = data; timer(5000).pipe( - switchMap(() => this.finishFlow(token)), + switchMap(() => this.finishFlow(String(params['access']), String(params['refresh']), access, refresh)), ).subscribe(); } }); diff --git a/src/app/state/user/actions.ts b/src/app/state/user/actions.ts index d121291..5d0917f 100644 --- a/src/app/state/user/actions.ts +++ b/src/app/state/user/actions.ts @@ -1,4 +1,4 @@ -import { DisplayPrefs } from "@lib/models/user"; +import { AccessTokenClaims, DisplayPrefs, RefreshTokenClaims } from "@lib/models/user"; const prefix = '[user]'; @@ -12,7 +12,7 @@ export class GoogleSignInFlow { export class FinishGoogleSignInFlow { static type = `${prefix} finish google sign-in flow` - constructor(readonly accessToken: string) { + constructor(readonly accessToken: string, readonly refreshToken: string, readonly accessClaims: AccessTokenClaims, readonly refreshClaims: RefreshTokenClaims) { } } @@ -40,3 +40,7 @@ export class SetColorMode { export class RefreshPaymentMethod { static type = `${prefix} refresh payment methods`; } + +export class RefreshAccessToken { + static type = `${prefix} refresh access token` +} diff --git a/src/app/state/user/index.ts b/src/app/state/user/index.ts index 29ae4f3..6e6c5e7 100644 --- a/src/app/state/user/index.ts +++ b/src/app/state/user/index.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { EnvironmentProviders, inject, Injectable } from '@angular/core'; import { PaymentMethodLookup } from '@lib/models/payment-method-lookup'; -import { DisplayPrefs, UserPrefs } from '@lib/models/user'; +import { AccessTokenClaimsSchema, DisplayPrefs, RefreshTokenClaimsSchema, UserPrefs } from '@lib/models/user'; import { Navigate } from '@ngxs/router-plugin'; import { Action, @@ -14,20 +14,24 @@ import { } from '@ngxs/store'; import { patch } from '@ngxs/store/operators'; import { jwtDecode } from 'jwt-decode'; -import { catchError, tap, throwError } from 'rxjs'; -import { FinishGoogleSignInFlow, GoogleSignInFlow, PrefsUpdated, RefreshPaymentMethod, SetColorMode, SignedIn, SignOut, UpdatePrefs } from './actions'; +import { catchError, map, tap, throwError } from 'rxjs'; +import { z } from 'zod'; +import { FinishGoogleSignInFlow, GoogleSignInFlow, PrefsUpdated, RefreshAccessToken, RefreshPaymentMethod, SetColorMode, SignedIn, SignOut, UpdatePrefs } from './actions'; export * from './actions'; -export type Principal = { - name: string; - email: string; - sub: number; - image?: string; -} +const PrincipalSchema = AccessTokenClaimsSchema.pick({ + email: true, + image: true, + name: true, + sub: true, +}); + +export type Principal = z.infer; export type UserStateModel = { - token?: string; + accessToken?: string; + refreshToken?: string; signedIn: boolean; principal?: Principal; prefs?: UserPrefs; @@ -45,6 +49,15 @@ const defaultState: UserStateModel = { signedIn: false, paymentMethods: [] }; type Context = StateContext; +export const ParamsSchema = z.object({ + access: z.string() + .transform(t => jwtDecode(t)) + .pipe(AccessTokenClaimsSchema), + refresh: z.string() + .transform(t => jwtDecode(t)) + .pipe(RefreshTokenClaimsSchema) +}); + @State({ name: USER, defaults: defaultState @@ -53,6 +66,25 @@ type Context = StateContext; class UserState implements NgxsOnInit { private http = inject(HttpClient); + @Action(RefreshAccessToken) + onRefreshAccessToken(ctx: Context) { + const { refreshToken } = ctx.getState(); + return this.http.get<{ access: string, refresh: string }>('/api/auth/refresh', { params: { token: refreshToken ?? '' } }).pipe( + map(arg => ({ + ...ParamsSchema.parse(arg), + accessToken: arg.access, + refreshToken: arg.refresh + })), + tap(({ access, accessToken, refreshToken }) => { + return ctx.setState(patch({ + refreshToken, + accessToken, + principal: PrincipalSchema.parse(access) + })); + }) + ) + } + @Action(RefreshPaymentMethod) onRefreshPaymentMethod(ctx: Context) { return this.http.get('/api/payment/methods').pipe( @@ -119,26 +151,21 @@ class UserState implements NgxsOnInit { } @Action(FinishGoogleSignInFlow) - finishGoogleSignInFlow(ctx: Context, { accessToken }: FinishGoogleSignInFlow) { - try { - const data = jwtDecode(accessToken); - ctx.setState(patch({ - signedIn: true, - principal: data, - token: accessToken - })); - const redirect = localStorage.getItem('auth-redirect'); - localStorage.removeItem('auth-redirect'); - ctx.dispatch([SignedIn, new Navigate([redirect ?? '/'])]); - } catch (error) { - ctx.setState(defaultState); - console.error(error); - throw error; - } + finishGoogleSignInFlow(ctx: Context, { accessToken, refreshToken, accessClaims }: FinishGoogleSignInFlow) { + ctx.setState(patch({ + signedIn: true, + principal: PrincipalSchema.parse(accessClaims), + accessToken, + refreshToken + })); + + const redirect = localStorage.getItem('auth-redirect'); + localStorage.removeItem('auth-redirect'); + ctx.dispatch([SignedIn, new Navigate([redirect ?? '/'])]); } ngxsOnInit(ctx: Context) { - const { token } = ctx.getState(); + const { accessToken: token } = ctx.getState(); if (!token) return; const { exp } = jwtDecode(token); const now = Date.now(); @@ -157,7 +184,7 @@ const slices = createPropertySelectors(USER); export const isUserSignedIn = createSelector([USER], state => state?.signedIn); export const principal = slices.principal; -export const accessToken = slices.token; +export const accessToken = slices.accessToken; export const preferences = createSelector([slices.prefs], prefs => { if (!prefs) { return defaultDisplayPrefs;