work-in-progress atproto PDS
typescript atproto pds atcute
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: TOTP MFA implementation

Mary bdffaf80 5480293e

+2954 -280
+3 -1
.prettierrc
··· 20 20 "<THIRD_PARTY_MODULES>", 21 21 "", 22 22 "^#app/(.*)$", 23 - "^#frontend/(.*)$", 23 + "", 24 + "^#web/(.*)$", 24 25 "", 25 26 "^\\.\\.", 26 27 "", 27 28 "^\\." 28 29 ], 30 + "importOrderParserPlugins": ["typescript", "jsx", "decorators"], 29 31 "importOrderTypeScriptVersion": "5.0.0", 30 32 31 33 "tailwindStylesheet": "./packages/danaus/src/web/styles/main.css",
+32
packages/danaus/drizzle/accounts/20260105115134_early_starbolt/migration.sql packages/danaus/drizzle/accounts/20260111233752_furry_franklin_storm/migration.sql
··· 60 60 CONSTRAINT `fk_legacy_session_next_id_legacy_session_id_fk` FOREIGN KEY (`next_id`) REFERENCES `legacy_session`(`id`) ON DELETE CASCADE 61 61 ); 62 62 --> statement-breakpoint 63 + CREATE TABLE `mfa_challenge` ( 64 + `token` text PRIMARY KEY, 65 + `did` text NOT NULL, 66 + `created_at` integer NOT NULL, 67 + `expires_at` integer NOT NULL, 68 + CONSTRAINT `fk_mfa_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 69 + ); 70 + --> statement-breakpoint 71 + CREATE TABLE `recovery_code` ( 72 + `id` integer PRIMARY KEY AUTOINCREMENT, 73 + `did` text NOT NULL, 74 + `code` text NOT NULL, 75 + `used_at` integer, 76 + `created_at` integer NOT NULL, 77 + CONSTRAINT `fk_recovery_code_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 78 + ); 79 + --> statement-breakpoint 80 + CREATE TABLE `totp_credential` ( 81 + `id` integer PRIMARY KEY AUTOINCREMENT, 82 + `did` text NOT NULL, 83 + `name` text NOT NULL, 84 + `secret` blob NOT NULL, 85 + `created_at` integer NOT NULL, 86 + `last_used_counter` integer NOT NULL, 87 + CONSTRAINT `fk_totp_credential_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE, 88 + CONSTRAINT `totp_credential_did_name_unique` UNIQUE(`did`,`name`) 89 + ); 90 + --> statement-breakpoint 63 91 CREATE TABLE `web_session` ( 64 92 `id` text PRIMARY KEY, 65 93 `did` text NOT NULL, 66 94 `metadata` text NOT NULL, 67 95 `created_at` integer NOT NULL, 68 96 `expires_at` integer NOT NULL, 97 + `sudo_at` integer, 69 98 CONSTRAINT `fk_web_session_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 70 99 ); 71 100 --> statement-breakpoint ··· 73 102 CREATE UNIQUE INDEX `account_handle_lower_idx` ON `account` (lower("handle"));--> statement-breakpoint 74 103 CREATE UNIQUE INDEX `account_email_lower_idx` ON `account` (lower("email"));--> statement-breakpoint 75 104 CREATE INDEX `legacy_session_did_idx` ON `legacy_session` (`did`);--> statement-breakpoint 105 + CREATE INDEX `mfa_challenge_expires_idx` ON `mfa_challenge` (`expires_at`);--> statement-breakpoint 106 + CREATE INDEX `recovery_code_did_idx` ON `recovery_code` (`did`);--> statement-breakpoint 107 + CREATE INDEX `totp_credential_did_idx` ON `totp_credential` (`did`);--> statement-breakpoint 76 108 CREATE INDEX `web_session_did_idx` ON `web_session` (`did`);
+297 -1
packages/danaus/drizzle/accounts/20260105115134_early_starbolt/snapshot.json packages/danaus/drizzle/accounts/20260111233752_furry_franklin_storm/snapshot.json
··· 1 1 { 2 2 "version": "7", 3 3 "dialect": "sqlite", 4 - "id": "cfbe3ca9-50aa-4487-9cd4-b30d846fc0cd", 4 + "id": "5f7c0714-f369-47f1-88ab-4294528beada", 5 5 "prevIds": [ 6 6 "00000000-0000-0000-0000-000000000000" 7 7 ], ··· 28 28 }, 29 29 { 30 30 "name": "legacy_session", 31 + "entityType": "tables" 32 + }, 33 + { 34 + "name": "mfa_challenge", 35 + "entityType": "tables" 36 + }, 37 + { 38 + "name": "recovery_code", 39 + "entityType": "tables" 40 + }, 41 + { 42 + "name": "totp_credential", 31 43 "entityType": "tables" 32 44 }, 33 45 { ··· 380 392 "autoincrement": false, 381 393 "default": null, 382 394 "generated": null, 395 + "name": "token", 396 + "entityType": "columns", 397 + "table": "mfa_challenge" 398 + }, 399 + { 400 + "type": "text", 401 + "notNull": true, 402 + "autoincrement": false, 403 + "default": null, 404 + "generated": null, 405 + "name": "did", 406 + "entityType": "columns", 407 + "table": "mfa_challenge" 408 + }, 409 + { 410 + "type": "integer", 411 + "notNull": true, 412 + "autoincrement": false, 413 + "default": null, 414 + "generated": null, 415 + "name": "created_at", 416 + "entityType": "columns", 417 + "table": "mfa_challenge" 418 + }, 419 + { 420 + "type": "integer", 421 + "notNull": true, 422 + "autoincrement": false, 423 + "default": null, 424 + "generated": null, 425 + "name": "expires_at", 426 + "entityType": "columns", 427 + "table": "mfa_challenge" 428 + }, 429 + { 430 + "type": "integer", 431 + "notNull": false, 432 + "autoincrement": true, 433 + "default": null, 434 + "generated": null, 435 + "name": "id", 436 + "entityType": "columns", 437 + "table": "recovery_code" 438 + }, 439 + { 440 + "type": "text", 441 + "notNull": true, 442 + "autoincrement": false, 443 + "default": null, 444 + "generated": null, 445 + "name": "did", 446 + "entityType": "columns", 447 + "table": "recovery_code" 448 + }, 449 + { 450 + "type": "text", 451 + "notNull": true, 452 + "autoincrement": false, 453 + "default": null, 454 + "generated": null, 455 + "name": "code", 456 + "entityType": "columns", 457 + "table": "recovery_code" 458 + }, 459 + { 460 + "type": "integer", 461 + "notNull": false, 462 + "autoincrement": false, 463 + "default": null, 464 + "generated": null, 465 + "name": "used_at", 466 + "entityType": "columns", 467 + "table": "recovery_code" 468 + }, 469 + { 470 + "type": "integer", 471 + "notNull": true, 472 + "autoincrement": false, 473 + "default": null, 474 + "generated": null, 475 + "name": "created_at", 476 + "entityType": "columns", 477 + "table": "recovery_code" 478 + }, 479 + { 480 + "type": "integer", 481 + "notNull": false, 482 + "autoincrement": true, 483 + "default": null, 484 + "generated": null, 485 + "name": "id", 486 + "entityType": "columns", 487 + "table": "totp_credential" 488 + }, 489 + { 490 + "type": "text", 491 + "notNull": true, 492 + "autoincrement": false, 493 + "default": null, 494 + "generated": null, 495 + "name": "did", 496 + "entityType": "columns", 497 + "table": "totp_credential" 498 + }, 499 + { 500 + "type": "text", 501 + "notNull": true, 502 + "autoincrement": false, 503 + "default": null, 504 + "generated": null, 505 + "name": "name", 506 + "entityType": "columns", 507 + "table": "totp_credential" 508 + }, 509 + { 510 + "type": "blob", 511 + "notNull": true, 512 + "autoincrement": false, 513 + "default": null, 514 + "generated": null, 515 + "name": "secret", 516 + "entityType": "columns", 517 + "table": "totp_credential" 518 + }, 519 + { 520 + "type": "integer", 521 + "notNull": true, 522 + "autoincrement": false, 523 + "default": null, 524 + "generated": null, 525 + "name": "created_at", 526 + "entityType": "columns", 527 + "table": "totp_credential" 528 + }, 529 + { 530 + "type": "integer", 531 + "notNull": true, 532 + "autoincrement": false, 533 + "default": null, 534 + "generated": null, 535 + "name": "last_used_counter", 536 + "entityType": "columns", 537 + "table": "totp_credential" 538 + }, 539 + { 540 + "type": "text", 541 + "notNull": false, 542 + "autoincrement": false, 543 + "default": null, 544 + "generated": null, 383 545 "name": "id", 384 546 "entityType": "columns", 385 547 "table": "web_session" ··· 421 583 "default": null, 422 584 "generated": null, 423 585 "name": "expires_at", 586 + "entityType": "columns", 587 + "table": "web_session" 588 + }, 589 + { 590 + "type": "integer", 591 + "notNull": false, 592 + "autoincrement": false, 593 + "default": null, 594 + "generated": null, 595 + "name": "sudo_at", 424 596 "entityType": "columns", 425 597 "table": "web_session" 426 598 }, ··· 540 712 "onUpdate": "NO ACTION", 541 713 "onDelete": "CASCADE", 542 714 "nameExplicit": false, 715 + "name": "fk_mfa_challenge_did_account_did_fk", 716 + "entityType": "fks", 717 + "table": "mfa_challenge" 718 + }, 719 + { 720 + "columns": [ 721 + "did" 722 + ], 723 + "tableTo": "account", 724 + "columnsTo": [ 725 + "did" 726 + ], 727 + "onUpdate": "NO ACTION", 728 + "onDelete": "CASCADE", 729 + "nameExplicit": false, 730 + "name": "fk_recovery_code_did_account_did_fk", 731 + "entityType": "fks", 732 + "table": "recovery_code" 733 + }, 734 + { 735 + "columns": [ 736 + "did" 737 + ], 738 + "tableTo": "account", 739 + "columnsTo": [ 740 + "did" 741 + ], 742 + "onUpdate": "NO ACTION", 743 + "onDelete": "CASCADE", 744 + "nameExplicit": false, 745 + "name": "fk_totp_credential_did_account_did_fk", 746 + "entityType": "fks", 747 + "table": "totp_credential" 748 + }, 749 + { 750 + "columns": [ 751 + "did" 752 + ], 753 + "tableTo": "account", 754 + "columnsTo": [ 755 + "did" 756 + ], 757 + "onUpdate": "NO ACTION", 758 + "onDelete": "CASCADE", 759 + "nameExplicit": false, 543 760 "name": "fk_web_session_did_account_did_fk", 544 761 "entityType": "fks", 545 762 "table": "web_session" ··· 602 819 }, 603 820 { 604 821 "columns": [ 822 + "token" 823 + ], 824 + "nameExplicit": false, 825 + "name": "mfa_challenge_pk", 826 + "table": "mfa_challenge", 827 + "entityType": "pks" 828 + }, 829 + { 830 + "columns": [ 831 + "id" 832 + ], 833 + "nameExplicit": false, 834 + "name": "recovery_code_pk", 835 + "table": "recovery_code", 836 + "entityType": "pks" 837 + }, 838 + { 839 + "columns": [ 840 + "id" 841 + ], 842 + "nameExplicit": false, 843 + "name": "totp_credential_pk", 844 + "table": "totp_credential", 845 + "entityType": "pks" 846 + }, 847 + { 848 + "columns": [ 605 849 "id" 606 850 ], 607 851 "nameExplicit": false, ··· 672 916 { 673 917 "columns": [ 674 918 { 919 + "value": "expires_at", 920 + "isExpression": false 921 + } 922 + ], 923 + "isUnique": false, 924 + "where": null, 925 + "origin": "manual", 926 + "name": "mfa_challenge_expires_idx", 927 + "entityType": "indexes", 928 + "table": "mfa_challenge" 929 + }, 930 + { 931 + "columns": [ 932 + { 933 + "value": "did", 934 + "isExpression": false 935 + } 936 + ], 937 + "isUnique": false, 938 + "where": null, 939 + "origin": "manual", 940 + "name": "recovery_code_did_idx", 941 + "entityType": "indexes", 942 + "table": "recovery_code" 943 + }, 944 + { 945 + "columns": [ 946 + { 947 + "value": "did", 948 + "isExpression": false 949 + } 950 + ], 951 + "isUnique": false, 952 + "where": null, 953 + "origin": "manual", 954 + "name": "totp_credential_did_idx", 955 + "entityType": "indexes", 956 + "table": "totp_credential" 957 + }, 958 + { 959 + "columns": [ 960 + { 675 961 "value": "did", 676 962 "isExpression": false 677 963 } ··· 692 978 "name": "app_password_did_name_unique", 693 979 "entityType": "uniques", 694 980 "table": "app_password" 981 + }, 982 + { 983 + "columns": [ 984 + "did", 985 + "name" 986 + ], 987 + "nameExplicit": false, 988 + "name": "totp_credential_did_name_unique", 989 + "entityType": "uniques", 990 + "table": "totp_credential" 695 991 } 696 992 ], 697 993 "renames": []
+5 -2
packages/danaus/package.json
··· 4 4 "type": "module", 5 5 "private": true, 6 6 "imports": { 7 - "#app/*": "./src/*" 7 + "#app/*": "./src/*", 8 + "#web/*": "./src/web/*" 8 9 }, 9 10 "exports": { 10 11 ".": "./src/index.ts" ··· 43 44 "@atcute/xrpc-server-bun": "^0.1.1", 44 45 "@kelinci/danaus-lexicons": "workspace:*", 45 46 "@oomfware/fetch-router": "^0.2.1", 46 - "@oomfware/forms": "^0.2.0", 47 + "@oomfware/forms": "^0.2.2", 47 48 "@oomfware/jsx": "^0.1.4", 48 49 "cva": "1.0.0-beta.4", 49 50 "drizzle-orm": "1.0.0-beta.6-4414a19", ··· 51 52 "jose": "^6.1.3", 52 53 "nanoid": "^5.1.6", 53 54 "p-queue": "^9.1.0", 55 + "qrcode": "^1.5.4", 54 56 "valibot": "^1.2.0" 55 57 }, 56 58 "devDependencies": { ··· 58 60 "@standard-schema/spec": "^1.1.0", 59 61 "@tailwindcss/cli": "^4.1.18", 60 62 "@types/bun": "^1.3.5", 63 + "@types/qrcode": "^1.5.6", 61 64 "concurrently": "^9.2.1", 62 65 "drizzle-kit": "1.0.0-beta.6-4414a19", 63 66 "tailwindcss": "^4.1.18"
+68
packages/danaus/src/accounts/db/schema.ts
··· 2 2 3 3 import { sql } from 'drizzle-orm'; 4 4 import { 5 + blob, 5 6 foreignKey, 6 7 index, 7 8 integer, ··· 109 110 110 111 created_at: integer({ mode: 'timestamp' }).notNull(), 111 112 expires_at: integer({ mode: 'timestamp' }).notNull(), 113 + 114 + /** when sudo mode was last activated (null = not in sudo mode) */ 115 + sudo_at: integer({ mode: 'timestamp' }), 112 116 }, 113 117 (t) => [index('web_session_did_idx').on(t.did)], 114 118 ); ··· 162 166 }, 163 167 (t) => [primaryKey({ columns: [t.code, t.used_by] })], 164 168 ); 169 + 170 + // #region TOTP two-factor authentication 171 + 172 + /** TOTP credentials for two-factor authentication */ 173 + export const totpCredential = sqliteTable( 174 + 'totp_credential', 175 + { 176 + id: integer().primaryKey({ autoIncrement: true }), 177 + 178 + did: text() 179 + .$type<Did>() 180 + .notNull() 181 + .references(() => account.did, { onDelete: 'cascade' }), 182 + 183 + /** user-provided or auto-generated name */ 184 + name: text().notNull(), 185 + /** 20-byte TOTP secret */ 186 + secret: blob({ mode: 'buffer' }).notNull(), 187 + 188 + created_at: integer({ mode: 'timestamp' }).notNull(), 189 + /** last TOTP counter value used (prevents replay attacks) */ 190 + last_used_counter: integer().notNull(), 191 + }, 192 + (t) => [index('totp_credential_did_idx').on(t.did), unique().on(t.did, t.name)], 193 + ); 194 + 195 + /** backup codes for account recovery */ 196 + export const recoveryCode = sqliteTable( 197 + 'recovery_code', 198 + { 199 + id: integer().primaryKey({ autoIncrement: true }), 200 + 201 + did: text() 202 + .$type<Did>() 203 + .notNull() 204 + .references(() => account.did, { onDelete: 'cascade' }), 205 + 206 + /** plaintext recovery code */ 207 + code: text().notNull(), 208 + 209 + used_at: integer({ mode: 'timestamp' }), 210 + created_at: integer({ mode: 'timestamp' }).notNull(), 211 + }, 212 + (t) => [index('recovery_code_did_idx').on(t.did)], 213 + ); 214 + 215 + /** MFA challenges during login */ 216 + export const mfaChallenge = sqliteTable( 217 + 'mfa_challenge', 218 + { 219 + token: text().primaryKey(), 220 + 221 + did: text() 222 + .$type<Did>() 223 + .notNull() 224 + .references(() => account.did, { onDelete: 'cascade' }), 225 + 226 + created_at: integer({ mode: 'timestamp' }).notNull(), 227 + expires_at: integer({ mode: 'timestamp' }).notNull(), 228 + }, 229 + (t) => [index('mfa_challenge_expires_idx').on(t.expires_at)], 230 + ); 231 + 232 + // #endregion
+357
packages/danaus/src/accounts/manager.ts
··· 19 19 import { AppPasswordPrivilege, EmailTokenPurpose } from './db/schema'; 20 20 import { isServiceDomain, isValidTld } from './handle'; 21 21 import { hashPassword, verifyPassword } from './passwords'; 22 + import { generateBackupCodes, MAX_TOTP_CREDENTIALS, verifyTotpCode } from './totp'; 22 23 import { AccountStatus, formatAccountStatus } from './types'; 23 24 24 25 const WEB_SESSION_TTL_MS = 7 * DAY; ··· 26 27 const LEGACY_ACCESS_TTL_MS = 2 * HOUR; 27 28 const LEGACY_REFRESH_TTL_MS = 90 * DAY; 28 29 const LEGACY_REFRESH_GRACE_TTL_MS = 2 * HOUR; 30 + const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes 31 + const SUDO_MODE_TTL_MS = 15 * 60 * 1000; // 15 minutes 29 32 export const MAX_APP_PASSWORDS = 25; 30 33 31 34 export type Account = typeof t.account.$inferSelect; ··· 34 37 export type WebSession = typeof t.webSession.$inferSelect; 35 38 export type InviteCode = typeof t.inviteCode.$inferSelect; 36 39 export type InviteCodeUse = typeof t.inviteCodeUse.$inferSelect; 40 + export type TotpCredential = typeof t.totpCredential.$inferSelect; 41 + export type BackupCode = typeof t.recoveryCode.$inferSelect; 42 + export type MfaChallenge = typeof t.mfaChallenge.$inferSelect; 37 43 38 44 export interface InviteCodeWithUses extends InviteCode { 39 45 uses: InviteCodeUse[]; ··· 1040 1046 1041 1047 // #endregion 1042 1048 1049 + // #region TOTP two-factor authentication 1050 + 1051 + /** 1052 + * create a TOTP credential for an account. 1053 + * @param options TOTP credential options 1054 + * @returns created credential 1055 + */ 1056 + createTotpCredential(options: CreateTotpCredentialOptions): TotpCredential { 1057 + const count = this.countTotpCredentials(options.did); 1058 + if (count >= MAX_TOTP_CREDENTIALS) { 1059 + throw new InvalidRequestError({ 1060 + error: 'TooManyTotpCredentials', 1061 + description: `cannot have more than ${MAX_TOTP_CREDENTIALS} authenticators`, 1062 + }); 1063 + } 1064 + 1065 + const name = options.name?.trim() || this.generateTotpName(options.did); 1066 + 1067 + // check for duplicate name 1068 + const existing = this.db 1069 + .select() 1070 + .from(t.totpCredential) 1071 + .where(and(eq(t.totpCredential.did, options.did), eq(t.totpCredential.name, name))) 1072 + .get(); 1073 + 1074 + if (existing) { 1075 + throw new InvalidRequestError({ 1076 + error: 'DuplicateTotpName', 1077 + description: `an authenticator with this name already exists`, 1078 + }); 1079 + } 1080 + 1081 + const inserted = this.db 1082 + .insert(t.totpCredential) 1083 + .values({ 1084 + did: options.did, 1085 + name: name, 1086 + secret: Buffer.from(options.secret), 1087 + created_at: new Date(), 1088 + last_used_counter: options.lastUsedCounter, 1089 + }) 1090 + .returning() 1091 + .get(); 1092 + 1093 + if (!inserted) { 1094 + throw new Error(`failed to create TOTP credential`); 1095 + } 1096 + 1097 + return inserted; 1098 + } 1099 + 1100 + /** 1101 + * list TOTP credentials for an account. 1102 + * @param did account did 1103 + * @returns TOTP credentials 1104 + */ 1105 + listTotpCredentials(did: Did): TotpCredential[] { 1106 + return this.db.select().from(t.totpCredential).where(eq(t.totpCredential.did, did)).all(); 1107 + } 1108 + 1109 + /** 1110 + * get a TOTP credential by id. 1111 + * @param did account did 1112 + * @param id credential id 1113 + * @returns TOTP credential or null 1114 + */ 1115 + getTotpCredential(did: Did, id: number): TotpCredential | null { 1116 + const credential = this.db 1117 + .select() 1118 + .from(t.totpCredential) 1119 + .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 1120 + .get(); 1121 + 1122 + return credential ?? null; 1123 + } 1124 + 1125 + /** 1126 + * delete a TOTP credential. 1127 + * @param did account did 1128 + * @param id credential id 1129 + */ 1130 + deleteTotpCredential(did: Did, id: number): void { 1131 + this.db 1132 + .delete(t.totpCredential) 1133 + .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 1134 + .run(); 1135 + } 1136 + 1137 + /** 1138 + * check if MFA is enabled for an account. 1139 + * @param did account did 1140 + * @returns true if at least one MFA credential exists 1141 + */ 1142 + isMfaEnabled(did: Did): boolean { 1143 + const count = 1144 + this.db 1145 + .select({ count: sql<number>`count(*)` }) 1146 + .from(t.totpCredential) 1147 + .where(eq(t.totpCredential.did, did)) 1148 + .get()?.count ?? 0; 1149 + 1150 + return count > 0; 1151 + } 1152 + 1153 + /** 1154 + * count TOTP credentials for an account. 1155 + * @param did account did 1156 + * @returns number of credentials 1157 + */ 1158 + countTotpCredentials(did: Did): number { 1159 + return ( 1160 + this.db 1161 + .select({ count: sql<number>`count(*)` }) 1162 + .from(t.totpCredential) 1163 + .where(eq(t.totpCredential.did, did)) 1164 + .get()?.count ?? 0 1165 + ); 1166 + } 1167 + 1168 + /** 1169 + * verify a TOTP code against any of the account's credentials. 1170 + * updates last_used_counter on successful verification to prevent replay attacks. 1171 + * @param did account did 1172 + * @param code the code to verify 1173 + * @returns true if the code is valid for any credential 1174 + */ 1175 + async verifyAccountTotpCode(did: Did, code: string): Promise<boolean> { 1176 + const credentials = this.listTotpCredentials(did); 1177 + 1178 + for (const credential of credentials) { 1179 + const counter = await verifyTotpCode(credential.secret, code, credential.last_used_counter); 1180 + 1181 + if (counter !== null) { 1182 + // update last_used_counter to prevent replay attacks 1183 + this.db 1184 + .update(t.totpCredential) 1185 + .set({ last_used_counter: counter }) 1186 + .where(eq(t.totpCredential.id, credential.id)) 1187 + .run(); 1188 + 1189 + return true; 1190 + } 1191 + } 1192 + 1193 + return false; 1194 + } 1195 + 1196 + /** 1197 + * generate a unique name for a new TOTP credential. 1198 + * @param did account did 1199 + * @returns generated name like "Authenticator" or "Authenticator 2" 1200 + */ 1201 + generateTotpName(did: Did): string { 1202 + const existing = this.listTotpCredentials(did); 1203 + const baseName = 'Authenticator'; 1204 + 1205 + if (existing.length === 0) { 1206 + return baseName; 1207 + } 1208 + 1209 + // find the next available number 1210 + const existingNames = new Set(existing.map((c) => c.name)); 1211 + let num = 2; 1212 + while (existingNames.has(`${baseName} ${num}`)) { 1213 + num++; 1214 + } 1215 + 1216 + return `${baseName} ${num}`; 1217 + } 1218 + 1219 + // #endregion 1220 + 1221 + // #region backup codes 1222 + 1223 + /** 1224 + * generate and store recovery codes for an account. 1225 + * deletes any existing codes first. 1226 + * @param did account did 1227 + */ 1228 + generateRecoveryCodes(did: Did): void { 1229 + const codes = generateBackupCodes(); 1230 + const now = new Date(); 1231 + 1232 + this.db.transaction((tx) => { 1233 + tx.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run(); 1234 + 1235 + for (const code of codes) { 1236 + tx.insert(t.recoveryCode) 1237 + .values({ 1238 + did: did, 1239 + code: code, 1240 + created_at: now, 1241 + }) 1242 + .run(); 1243 + } 1244 + }); 1245 + } 1246 + 1247 + /** 1248 + * get all unused recovery codes for an account. 1249 + * @param did account did 1250 + * @returns array of unused codes 1251 + */ 1252 + getRecoveryCodes(did: Did): string[] { 1253 + return this.db 1254 + .select({ code: t.recoveryCode.code }) 1255 + .from(t.recoveryCode) 1256 + .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at))) 1257 + .all() 1258 + .map((row) => row.code); 1259 + } 1260 + 1261 + /** 1262 + * get count of unused recovery codes. 1263 + * @param did account did 1264 + * @returns number of unused codes 1265 + */ 1266 + getRecoveryCodeCount(did: Did): number { 1267 + return ( 1268 + this.db 1269 + .select({ count: sql<number>`count(*)` }) 1270 + .from(t.recoveryCode) 1271 + .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at))) 1272 + .get()?.count ?? 0 1273 + ); 1274 + } 1275 + 1276 + /** 1277 + * verify and consume a recovery code. 1278 + * @param did account did 1279 + * @param code the code to verify 1280 + * @returns true if the code was valid and consumed 1281 + */ 1282 + consumeRecoveryCode(did: Did, code: string): boolean { 1283 + const result = this.db 1284 + .update(t.recoveryCode) 1285 + .set({ used_at: new Date() }) 1286 + .where(and(eq(t.recoveryCode.did, did), eq(t.recoveryCode.code, code), isNull(t.recoveryCode.used_at))) 1287 + .returning() 1288 + .get(); 1289 + 1290 + return result != null; 1291 + } 1292 + 1293 + /** 1294 + * delete all recovery codes for an account. 1295 + * @param did account did 1296 + */ 1297 + deleteRecoveryCodes(did: Did): void { 1298 + this.db.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run(); 1299 + } 1300 + 1301 + // #endregion 1302 + 1303 + // #region MFA challenges 1304 + 1305 + /** 1306 + * create an MFA challenge for login. 1307 + * @param did account did 1308 + * @returns token for the MFA page 1309 + */ 1310 + createMfaChallenge(did: Did): string { 1311 + const token = nanoid(32); 1312 + const now = new Date(); 1313 + const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 1314 + 1315 + this.db 1316 + .insert(t.mfaChallenge) 1317 + .values({ 1318 + token: token, 1319 + did: did, 1320 + created_at: now, 1321 + expires_at: expiresAt, 1322 + }) 1323 + .run(); 1324 + 1325 + return token; 1326 + } 1327 + 1328 + /** 1329 + * get an MFA challenge by token. 1330 + * @param token the token 1331 + * @returns MFA challenge or null if expired/not found 1332 + */ 1333 + getMfaChallenge(token: string): MfaChallenge | null { 1334 + const challenge = this.db.select().from(t.mfaChallenge).where(eq(t.mfaChallenge.token, token)).get(); 1335 + 1336 + if (!challenge) { 1337 + return null; 1338 + } 1339 + 1340 + const now = new Date(); 1341 + if (challenge.expires_at <= now) { 1342 + this.db.delete(t.mfaChallenge).where(eq(t.mfaChallenge.token, token)).run(); 1343 + return null; 1344 + } 1345 + 1346 + return challenge; 1347 + } 1348 + 1349 + /** 1350 + * delete an MFA challenge. 1351 + * @param token the token 1352 + */ 1353 + deleteMfaChallenge(token: string): void { 1354 + this.db.delete(t.mfaChallenge).where(eq(t.mfaChallenge.token, token)).run(); 1355 + } 1356 + 1357 + /** 1358 + * clean up expired MFA challenges. 1359 + */ 1360 + cleanupExpiredMfaChallenges(): void { 1361 + const now = new Date(); 1362 + this.db.delete(t.mfaChallenge).where(lte(t.mfaChallenge.expires_at, now)).run(); 1363 + } 1364 + 1365 + // #endregion 1366 + 1367 + // #region sudo mode 1368 + 1369 + /** 1370 + * elevate a session to sudo mode. 1371 + * @param sessionId the session id 1372 + */ 1373 + elevateSession(sessionId: string): void { 1374 + this.db.update(t.webSession).set({ sudo_at: new Date() }).where(eq(t.webSession.id, sessionId)).run(); 1375 + } 1376 + 1377 + /** 1378 + * check if a session is in sudo mode. 1379 + * @param session the session 1380 + * @returns true if session is elevated 1381 + */ 1382 + isSessionElevated(session: WebSession): boolean { 1383 + if (session.sudo_at === null) { 1384 + return false; 1385 + } 1386 + const now = Date.now(); 1387 + const elevatedAt = session.sudo_at.getTime(); 1388 + return now - elevatedAt < SUDO_MODE_TTL_MS; 1389 + } 1390 + 1391 + // #endregion 1392 + 1043 1393 async importAccount(_options: ImportAccountOptions) {} 1044 1394 1045 1395 private resolveIdentifier(identifier: string, options: AccountAvailabilityOptions): Account | null { ··· 1156 1506 includeDisabled?: boolean; 1157 1507 includeUsed?: boolean; 1158 1508 } 1509 + 1510 + interface CreateTotpCredentialOptions { 1511 + did: Did; 1512 + name?: string; 1513 + secret: Uint8Array; 1514 + lastUsedCounter: number; 1515 + }
+223
packages/danaus/src/accounts/totp.ts
··· 1 + import { timingSafeEqual } from 'node:crypto'; 2 + 3 + import { fromBase32, toBase32 } from '@atcute/multibase'; 4 + 5 + import { customAlphabet } from 'nanoid'; 6 + import QRCode from 'qrcode'; 7 + 8 + // #region constants 9 + 10 + /** TOTP secret length in bytes (160 bits as per RFC 6238) */ 11 + const SECRET_LENGTH = 20; 12 + 13 + /** TOTP code length in digits */ 14 + export const TOTP_CODE_LENGTH = 6; 15 + 16 + /** TOTP code regex pattern */ 17 + export const TOTP_CODE_RE = /^\d{6}$/; 18 + 19 + /** TOTP time step in seconds */ 20 + const TIME_STEP = 30; 21 + 22 + /** TOTP time window tolerance (±1 step for clock drift) */ 23 + const TIME_WINDOW = 1; 24 + 25 + /** maximum TOTP credentials per account */ 26 + export const MAX_TOTP_CREDENTIALS = 5; 27 + 28 + /** number of backup codes to generate */ 29 + const RECOVERY_CODE_COUNT = 10; 30 + 31 + /** recovery code length in characters */ 32 + export const RECOVERY_CODE_LENGTH = 8; 33 + 34 + /** recovery code regex pattern */ 35 + export const RECOVERY_CODE_RE = /^[a-zA-Z\d]{4}-?[a-zA-Z\d]{4}$/; 36 + 37 + /** recovery code alphabet (no confusing characters: 0, 1, I, O, L) */ 38 + const RECOVERY_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; 39 + 40 + // #endregion 41 + 42 + // #region secret generation 43 + 44 + /** 45 + * generates a random TOTP secret. 46 + * @returns 20 random bytes 47 + */ 48 + export const generateSecret = (): Uint8Array => { 49 + const bytes = new Uint8Array(SECRET_LENGTH); 50 + crypto.getRandomValues(bytes); 51 + return bytes; 52 + }; 53 + 54 + /** 55 + * encodes a secret as base32 for display and storage. 56 + * @param secret the raw secret bytes 57 + * @returns uppercase base32 string 58 + */ 59 + export const encodeSecret = (secret: Uint8Array): string => { 60 + return toBase32(secret).toUpperCase(); 61 + }; 62 + 63 + /** 64 + * decodes a base32 secret string back to bytes. 65 + * @param encoded the base32 encoded secret 66 + * @returns raw secret bytes 67 + */ 68 + export const decodeSecret = (encoded: string): Uint8Array => { 69 + return fromBase32(encoded.toLowerCase()); 70 + }; 71 + 72 + // #endregion 73 + 74 + // #region TOTP URI and QR code 75 + 76 + /** 77 + * generates an otpauth:// URI for TOTP registration. 78 + * @param secret the raw secret bytes 79 + * @param label the account label (usually handle) 80 + * @param issuer the service name 81 + * @returns otpauth URI string 82 + */ 83 + export const generateTotpUri = (secret: Uint8Array, label: string, issuer: string): string => { 84 + const encodedSecret = encodeSecret(secret); 85 + const encodedLabel = encodeURIComponent(label); 86 + const encodedIssuer = encodeURIComponent(issuer); 87 + 88 + return `otpauth://totp/${encodedIssuer}:${encodedLabel}?secret=${encodedSecret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=${TOTP_CODE_LENGTH}&period=${TIME_STEP}`; 89 + }; 90 + 91 + /** 92 + * generates a QR code as a base64-encoded data URI. 93 + * @param uri the otpauth URI to encode 94 + * @returns base64 data URI for embedding in img src 95 + */ 96 + export const generateQrCode = async (uri: string): Promise<string> => { 97 + return QRCode.toDataURL(uri, { 98 + errorCorrectionLevel: 'M', 99 + margin: 2, 100 + width: 200, 101 + }); 102 + }; 103 + 104 + // #endregion 105 + 106 + // #region TOTP verification 107 + 108 + /** 109 + * generates a TOTP code for a given counter value. 110 + * implements RFC 4226 HOTP algorithm. 111 + * @param secret the raw secret bytes 112 + * @param counter the counter value (typically floor(time / 30)) 113 + * @returns 6-digit code 114 + */ 115 + const generateHotp = async (secret: Uint8Array, counter: number): Promise<string> => { 116 + // convert counter to 8-byte big-endian buffer 117 + const counterBuffer = new Uint8Array(8); 118 + for (let i = 7; i >= 0; i--) { 119 + counterBuffer[i] = counter & 0xff; 120 + counter = Math.floor(counter / 256); 121 + } 122 + 123 + // import key for HMAC-SHA1 124 + const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); 125 + 126 + // compute HMAC-SHA1 127 + const signature = await crypto.subtle.sign('HMAC', key, counterBuffer); 128 + const hash = new Uint8Array(signature); 129 + 130 + // dynamic truncation (RFC 4226) 131 + const offset = hash[hash.length - 1]! & 0x0f; 132 + const binary = 133 + ((hash[offset]! & 0x7f) << 24) | 134 + ((hash[offset + 1]! & 0xff) << 16) | 135 + ((hash[offset + 2]! & 0xff) << 8) | 136 + (hash[offset + 3]! & 0xff); 137 + 138 + // generate 6-digit code 139 + const code = binary % 10 ** TOTP_CODE_LENGTH; 140 + return code.toString().padStart(TOTP_CODE_LENGTH, '0'); 141 + }; 142 + 143 + /** 144 + * verifies a TOTP code against a secret. 145 + * checks current time step and ±1 for clock drift tolerance. 146 + * @param secret the raw secret bytes 147 + * @param code the code to verify 148 + * @param lastUsedCounter last used counter to prevent replay attacks (null if first use) 149 + * @returns the matched counter value if valid, or null if invalid 150 + */ 151 + export const verifyTotpCode = async ( 152 + secret: Uint8Array, 153 + code: string, 154 + lastUsedCounter: number | null, 155 + ): Promise<number | null> => { 156 + if (!isTotpCode(code)) { 157 + return null; 158 + } 159 + 160 + const codeBytes = Buffer.from(code); 161 + 162 + const currentTime = Math.floor(Date.now() / 1000); 163 + const currentCounter = Math.floor(currentTime / TIME_STEP); 164 + 165 + // check current window and ±1 for clock drift 166 + for (let i = -TIME_WINDOW; i <= TIME_WINDOW; i++) { 167 + const counter = currentCounter + i; 168 + 169 + // skip if this counter was already used (replay attack prevention) 170 + if (lastUsedCounter != null && counter <= lastUsedCounter) { 171 + continue; 172 + } 173 + 174 + const expectedCode = await generateHotp(secret, counter); 175 + 176 + if (timingSafeEqual(codeBytes, Buffer.from(expectedCode))) { 177 + return counter; 178 + } 179 + } 180 + 181 + return null; 182 + }; 183 + 184 + // #endregion 185 + 186 + // #region backup codes 187 + 188 + const generateBackupCode = customAlphabet(RECOVERY_CODE_ALPHABET, RECOVERY_CODE_LENGTH); 189 + 190 + /** 191 + * generates backup codes. 192 + * @returns array of 10 backup codes, 8 characters each 193 + */ 194 + export const generateBackupCodes = (): string[] => { 195 + const codes: string[] = []; 196 + for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { 197 + const raw = generateBackupCode(); 198 + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; 199 + 200 + codes.push(code); 201 + } 202 + return codes; 203 + }; 204 + 205 + /** 206 + * checks if a code is a valid TOTP code (6 digits). 207 + * @param code the code to check 208 + * @returns true if valid 209 + */ 210 + export const isTotpCode = (code: string): boolean => { 211 + return TOTP_CODE_RE.test(code); 212 + }; 213 + 214 + /** 215 + * checks if a code is a valid recovery code (8 alphanumeric chars). 216 + * @param code the code to check 217 + * @returns true if valid 218 + */ 219 + export const isRecoveryCode = (code: string): boolean => { 220 + return RECOVERY_CODE_RE.test(code); 221 + }; 222 + 223 + // #endregion
+3
packages/danaus/src/bin/pds.ts
··· 42 42 const shutdown = async () => { 43 43 console.log('\nshutting down...'); 44 44 await pds.close(); 45 + 46 + process.off('SIGINT', shutdown); 47 + process.off('SIGTERM', shutdown); 45 48 process.exit(0); 46 49 }; 47 50
-44
packages/danaus/src/web/account/forms.ts
··· 2 2 import type { Did, Handle } from '@atcute/lexicons'; 3 3 import { isHandle } from '@atcute/lexicons/syntax'; 4 4 import { XRPCError } from '@atcute/xrpc-server'; 5 - import { redirect } from '@oomfware/fetch-router'; 6 - import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 7 5 import { form, invalid } from '@oomfware/forms'; 8 6 9 7 import * as v from 'valibot'; 10 8 11 9 import { parseAppPasswordPrivilege } from '#app/accounts/app-passwords.ts'; 12 - import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 13 - import { setWebSessionToken } from '#app/auth/web.ts'; 14 10 import type { AppContext } from '#app/context.ts'; 15 11 import { isHostnameSuffix } from '#app/utils/schema.ts'; 16 12 17 13 import { getAppContext } from '../middlewares/app-context.ts'; 18 14 import { getSession } from '../middlewares/session.ts'; 19 - 20 - /** 21 - * validates credentials, creates session, sets cookie, and redirects. 22 - */ 23 - export const signInForm = form( 24 - v.object({ 25 - identifier: v.pipe(v.string(), v.minLength(1, `Enter your email or username`)), 26 - _password: v.pipe(v.string(), v.minLength(1, `Enter your password`)), 27 - remember: v.optional(v.boolean()), 28 - redirect: v.optional(v.string()), 29 - }), 30 - async (data, issue) => { 31 - const { accountManager } = getAppContext(); 32 - const { request } = getContext(); 33 - 34 - if (data._password.length < MIN_PASSWORD_LENGTH || data._password.length > MAX_PASSWORD_LENGTH) { 35 - invalid(issue.identifier(`Invalid account credentials`)); 36 - } 37 - 38 - const account = await accountManager.verifyAccountPassword(data.identifier, data._password); 39 - if (account === null) { 40 - invalid(issue.identifier(`Invalid account credentials`)); 41 - } 42 - 43 - const { session, token } = await accountManager.createWebSession({ 44 - did: account.did, 45 - remember: data.remember ?? false, 46 - userAgent: request.headers.get('user-agent') ?? undefined, 47 - }); 48 - 49 - setWebSessionToken(request, token, { 50 - expires: session.expires_at, 51 - httpOnly: true, 52 - sameSite: 'lax', 53 - path: '/', 54 - }); 55 - 56 - redirect(data.redirect ?? '/account'); 57 - }, 58 - ); 59 15 60 16 /** 61 17 * creates an app password and returns the secret for display.
+3 -169
packages/danaus/src/web/controllers/account.tsx
··· 14 14 import AtOutlined from '../icons/central/at-outlined.tsx'; 15 15 import DotGrid1x3HorizontalOutlined from '../icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 16 16 import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 17 - import PasskeysOutlined from '../icons/central/passkeys-outlined.tsx'; 18 - import PasswordOutlined from '../icons/central/password-outlined.tsx'; 19 - import PhoneOutlined from '../icons/central/phone-outlined.tsx'; 20 17 import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 21 - import UsbOutlined from '../icons/central/usb-outlined.tsx'; 22 18 import { AccountLayout } from '../layouts/account.tsx'; 23 19 import { getAppContext } from '../middlewares/app-context.ts'; 24 20 import { getSession, requireSession } from '../middlewares/session.ts'; ··· 37 33 import Dialog from '../primitives/dialog.tsx'; 38 34 import Field from '../primitives/field.tsx'; 39 35 import Input from '../primitives/input.tsx'; 40 - import MenuDivider from '../primitives/menu-divider.tsx'; 41 36 import MenuItem from '../primitives/menu-item.tsx'; 42 37 import MenuList from '../primitives/menu-list.tsx'; 43 38 import MenuPopover from '../primitives/menu-popover.tsx'; ··· 48 43 import MessageBar from '../primitives/message-bar.tsx'; 49 44 import Select from '../primitives/select.tsx'; 50 45 import type { routes } from '../routes.ts'; 46 + 47 + import security from './account/security.tsx'; 51 48 52 49 export default { 53 50 middleware: [ ··· 543 540 ); 544 541 }, 545 542 546 - security() { 547 - const ctx = getAppContext(); 548 - const session = getSession(); 549 - const account = ctx.accountManager.getAccount(session.did); 550 - 551 - return render( 552 - <AccountLayout> 553 - <title>Security - Danaus</title> 554 - 555 - <div class="flex flex-col gap-4"> 556 - <div class="flex h-8 shrink-0 items-center"> 557 - <h3 class="text-base-400 font-medium">Security</h3> 558 - </div> 559 - 560 - <div class="flex flex-col gap-8"> 561 - <div class="flex flex-col gap-2"> 562 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account information</h4> 563 - 564 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 565 - <div class="flex items-center gap-4 px-4 py-3"> 566 - <div class="min-w-0 grow"> 567 - <p class="text-base-300 font-medium wrap-break-word">{account?.email}</p> 568 - <p class="text-base-300 text-neutral-foreground-3"> 569 - {account?.email_confirmed_at ? 'Verified' : 'Not verified'} 570 - </p> 571 - </div> 572 - 573 - {!account?.email_confirmed_at && <Button>Verify</Button>} 574 - 575 - <Menu> 576 - <MenuTrigger> 577 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 578 - <DotGrid1x3HorizontalOutlined size={16} /> 579 - </button> 580 - </MenuTrigger> 581 - 582 - <MenuPopover> 583 - <MenuList> 584 - <MenuItem>Change email</MenuItem> 585 - </MenuList> 586 - </MenuPopover> 587 - </Menu> 588 - </div> 589 - </div> 590 - </div> 591 - 592 - <div class="flex flex-col gap-2"> 593 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Ways to prove who you are</h4> 594 - 595 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 596 - <div class="flex items-center gap-4 px-4 py-3"> 597 - <PasswordOutlined size={24} class="shrink-0" /> 598 - 599 - <div class="min-w-0 grow"> 600 - <p class="text-base-300 font-medium wrap-break-word">Password</p> 601 - <p class="text-base-300 text-neutral-foreground-3">Last changed yesterday</p> 602 - </div> 603 - 604 - <Menu> 605 - <MenuTrigger> 606 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 607 - <DotGrid1x3HorizontalOutlined size={16} /> 608 - </button> 609 - </MenuTrigger> 610 - 611 - <MenuPopover> 612 - <MenuList> 613 - <MenuItem>Change password</MenuItem> 614 - </MenuList> 615 - </MenuPopover> 616 - </Menu> 617 - </div> 618 - 619 - <div class="flex items-center gap-4 px-4 py-3"> 620 - <PhoneOutlined size={24} class="shrink-0" /> 621 - 622 - <div class="min-w-0 grow"> 623 - <p class="text-base-300 font-medium wrap-break-word">Bitwarden</p> 624 - <p class="text-base-300 text-neutral-foreground-3">Authenticator · Added yesterday</p> 625 - </div> 626 - 627 - <Menu> 628 - <MenuTrigger> 629 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 630 - <DotGrid1x3HorizontalOutlined size={16} /> 631 - </button> 632 - </MenuTrigger> 633 - 634 - <MenuPopover> 635 - <MenuList> 636 - <MenuItem>Rename</MenuItem> 637 - <MenuDivider /> 638 - <MenuItem>Remove</MenuItem> 639 - </MenuList> 640 - </MenuPopover> 641 - </Menu> 642 - </div> 643 - 644 - <div class="flex items-center gap-4 px-4 py-3"> 645 - <UsbOutlined size={24} class="shrink-0" /> 646 - 647 - <div class="min-w-0 grow"> 648 - <p class="text-base-300 font-medium wrap-break-word">YubiKey 5</p> 649 - <p class="text-base-300 text-neutral-foreground-3">Security key · Added 2 weeks ago</p> 650 - </div> 651 - 652 - <Menu> 653 - <MenuTrigger> 654 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 655 - <DotGrid1x3HorizontalOutlined size={16} /> 656 - </button> 657 - </MenuTrigger> 658 - 659 - <MenuPopover> 660 - <MenuList> 661 - <MenuItem>Rename</MenuItem> 662 - <MenuDivider /> 663 - <MenuItem>Remove</MenuItem> 664 - </MenuList> 665 - </MenuPopover> 666 - </Menu> 667 - </div> 668 - 669 - <div class="flex items-center gap-4 px-4 py-3"> 670 - <PasskeysOutlined size={24} class="shrink-0" /> 671 - 672 - <div class="min-w-0 grow"> 673 - <p class="text-base-300 font-medium wrap-break-word">iCloud Keychain</p> 674 - <p class="text-base-300 text-neutral-foreground-3">Passkey · Added last month</p> 675 - </div> 676 - 677 - <Menu> 678 - <MenuTrigger> 679 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 680 - <DotGrid1x3HorizontalOutlined size={16} /> 681 - </button> 682 - </MenuTrigger> 683 - 684 - <MenuPopover> 685 - <MenuList> 686 - <MenuItem>Rename</MenuItem> 687 - <MenuDivider /> 688 - <MenuItem>Remove</MenuItem> 689 - </MenuList> 690 - </MenuPopover> 691 - </Menu> 692 - </div> 693 - 694 - <button class="flex items-center gap-4 bg-subtle-background px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition select-none first:rounded-t-md last:rounded-b-md hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active"> 695 - <div class="grid h-6 w-6 shrink-0 place-items-center"> 696 - <PlusLargeOutlined size={16} /> 697 - </div> 698 - 699 - <div class="min-w-0 grow"> 700 - <p class="text-base-300">Add another way to sign in</p> 701 - </div> 702 - </button> 703 - </div> 704 - </div> 705 - </div> 706 - </div> 707 - </AccountLayout>, 708 - ); 709 - }, 543 + security, 710 544 }, 711 545 } satisfies Controller<typeof routes.account>;
+16
packages/danaus/src/web/controllers/account/security.tsx
··· 1 + import type { Controller } from '@oomfware/fetch-router'; 2 + 3 + import type { routes } from '#web/routes.ts'; 4 + 5 + import overview from './security/overview.tsx'; 6 + import recovery from './security/recovery.tsx'; 7 + import totp from './security/totp.tsx'; 8 + 9 + export default { 10 + middleware: [], 11 + actions: { 12 + overview, 13 + totp, 14 + recovery, 15 + }, 16 + } satisfies Controller<typeof routes.account.security>;
+285
packages/danaus/src/web/controllers/account/security/overview.tsx
··· 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + import { render } from '@oomfware/jsx'; 3 + 4 + import type { Account, TotpCredential } from '#app/accounts/manager.ts'; 5 + 6 + import DotGrid1x3HorizontalOutlined from '#web/icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 7 + import PasskeysOutlined from '#web/icons/central/passkeys-outlined.tsx'; 8 + import PasswordOutlined from '#web/icons/central/password-outlined.tsx'; 9 + import PhoneOutlined from '#web/icons/central/phone-outlined.tsx'; 10 + import PlusLargeOutlined from '#web/icons/central/plus-large-outlined.tsx'; 11 + import UsbOutlined from '#web/icons/central/usb-outlined.tsx'; 12 + import { AccountLayout } from '#web/layouts/account.tsx'; 13 + import { getAppContext } from '#web/middlewares/app-context.ts'; 14 + import { getSession } from '#web/middlewares/session.ts'; 15 + import Button from '#web/primitives/button.tsx'; 16 + import DialogActions from '#web/primitives/dialog-actions.tsx'; 17 + import DialogBody from '#web/primitives/dialog-body.tsx'; 18 + import DialogClose from '#web/primitives/dialog-close.tsx'; 19 + import DialogContent from '#web/primitives/dialog-content.tsx'; 20 + import DialogSurface from '#web/primitives/dialog-surface.tsx'; 21 + import DialogTitle from '#web/primitives/dialog-title.tsx'; 22 + import Dialog from '#web/primitives/dialog.tsx'; 23 + import MenuDivider from '#web/primitives/menu-divider.tsx'; 24 + import MenuItem from '#web/primitives/menu-item.tsx'; 25 + import MenuList from '#web/primitives/menu-list.tsx'; 26 + import MenuPopover from '#web/primitives/menu-popover.tsx'; 27 + import MenuTrigger from '#web/primitives/menu-trigger.tsx'; 28 + import Menu from '#web/primitives/menu.tsx'; 29 + import { routes } from '#web/routes.ts'; 30 + 31 + export default { 32 + middleware: [], 33 + action() { 34 + const { accountManager } = getAppContext(); 35 + const { did } = getSession(); 36 + const account = accountManager.getAccount(did)!; 37 + 38 + const totpCredentials = accountManager.listTotpCredentials(did); 39 + const hasTotp = totpCredentials.length > 0; 40 + 41 + return render( 42 + <AccountLayout> 43 + <title>Security - Danaus</title> 44 + 45 + <div class="flex flex-col gap-4"> 46 + <div class="flex h-8 shrink-0 items-center"> 47 + <h3 class="text-base-400 font-medium">Security</h3> 48 + </div> 49 + 50 + <div class="flex flex-col gap-8"> 51 + <InformationSection account={account} /> 52 + 53 + <AuthenticationSection account={account} totpCredentials={totpCredentials} /> 54 + 55 + {hasTotp && <RecoverySection />} 56 + </div> 57 + </div> 58 + </AccountLayout>, 59 + ); 60 + }, 61 + } satisfies BuildAction<'ANY', typeof routes.account.security.overview>; 62 + 63 + const InformationSection = ({ account }: { account: Account }) => { 64 + return ( 65 + <div class="flex flex-col gap-2"> 66 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account information</h4> 67 + 68 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 69 + <div class="flex items-center gap-4 px-4 py-3"> 70 + <div class="min-w-0 grow"> 71 + <p class="text-base-300 font-medium wrap-break-word">{account?.email}</p> 72 + <p class="text-base-300 text-neutral-foreground-3"> 73 + {account?.email_confirmed_at ? 'Verified' : 'Not verified'} 74 + </p> 75 + </div> 76 + 77 + {!account?.email_confirmed_at && <Button>Verify</Button>} 78 + 79 + <Menu> 80 + <MenuTrigger> 81 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 82 + <DotGrid1x3HorizontalOutlined size={16} /> 83 + </button> 84 + </MenuTrigger> 85 + 86 + <MenuPopover> 87 + <MenuList> 88 + <MenuItem>Change email</MenuItem> 89 + </MenuList> 90 + </MenuPopover> 91 + </Menu> 92 + </div> 93 + </div> 94 + </div> 95 + ); 96 + }; 97 + 98 + const AuthenticationSection = ({ 99 + account, 100 + totpCredentials, 101 + }: { 102 + account: Account; 103 + totpCredentials: TotpCredential[]; 104 + }) => { 105 + return ( 106 + <div class="flex flex-col gap-2"> 107 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Ways to prove who you are</h4> 108 + 109 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 110 + {/* Password (always shown) */} 111 + <div class="flex items-center gap-4 px-4 py-3"> 112 + <PasswordOutlined size={24} class="shrink-0" /> 113 + 114 + <div class="min-w-0 grow"> 115 + <p class="text-base-300 font-medium wrap-break-word">Password</p> 116 + <p class="text-base-300 text-neutral-foreground-3"> 117 + Last changed {(account.password_updated_at || account.created_at).toLocaleDateString()} 118 + </p> 119 + </div> 120 + 121 + <Menu> 122 + <MenuTrigger> 123 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 124 + <DotGrid1x3HorizontalOutlined size={16} /> 125 + </button> 126 + </MenuTrigger> 127 + 128 + <MenuPopover> 129 + <MenuList> 130 + <MenuItem>Change password</MenuItem> 131 + </MenuList> 132 + </MenuPopover> 133 + </Menu> 134 + </div> 135 + 136 + {/* TOTP credentials */} 137 + {totpCredentials.map((totp) => ( 138 + <div class="flex items-center gap-4 px-4 py-3"> 139 + <PhoneOutlined size={24} class="shrink-0" /> 140 + 141 + <div class="min-w-0 grow"> 142 + <p class="text-base-300 font-medium wrap-break-word">{totp.name}</p> 143 + <p class="text-base-300 text-neutral-foreground-3"> 144 + Authenticator · Added {totp.created_at.toLocaleDateString()} 145 + </p> 146 + </div> 147 + 148 + <Menu> 149 + <MenuTrigger> 150 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 151 + <DotGrid1x3HorizontalOutlined size={16} /> 152 + </button> 153 + </MenuTrigger> 154 + 155 + <MenuPopover> 156 + <MenuList> 157 + <MenuItem href={routes.account.security.totp.remove.href({ id: totp.id })}>Remove</MenuItem> 158 + </MenuList> 159 + </MenuPopover> 160 + </Menu> 161 + </div> 162 + ))} 163 + 164 + {/* Security keys placeholder (future) */} 165 + {/* Passkeys placeholder (future) */} 166 + 167 + {/* Add another way to sign in */} 168 + <button 169 + command="show-modal" 170 + commandfor="add-auth-method-dialog" 171 + class="flex items-center gap-4 bg-subtle-background px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition select-none first:rounded-t-md last:rounded-b-md hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active" 172 + > 173 + <div class="grid h-6 w-6 shrink-0 place-items-center"> 174 + <PlusLargeOutlined size={16} /> 175 + </div> 176 + 177 + <div class="min-w-0 grow"> 178 + <p class="text-base-300">Add another way to sign in</p> 179 + </div> 180 + </button> 181 + </div> 182 + 183 + <Dialog id="add-auth-method-dialog"> 184 + <DialogSurface> 185 + <DialogBody> 186 + <DialogTitle>Add sign-in method</DialogTitle> 187 + 188 + <DialogContent class="flex flex-col"> 189 + <a 190 + href={routes.account.security.totp.register.href()} 191 + class="flex items-center gap-4 rounded-md px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active" 192 + > 193 + <PhoneOutlined size={24} class="shrink-0" /> 194 + 195 + <div class="min-w-0 grow"> 196 + <p class="text-base-300 font-medium">Authenticator app</p> 197 + <p class="text-base-300 text-neutral-foreground-3"> 198 + Use an app like Google Authenticator or Bitwarden 199 + </p> 200 + </div> 201 + </a> 202 + 203 + <button disabled class="flex items-center gap-4 rounded-md px-4 py-3 text-left opacity-50"> 204 + <UsbOutlined size={24} class="shrink-0" /> 205 + 206 + <div class="min-w-0 grow"> 207 + <p class="text-base-300 font-medium">Security key</p> 208 + <p class="text-base-300 text-neutral-foreground-3"> 209 + Use a hardware key like YubiKey (coming soon) 210 + </p> 211 + </div> 212 + </button> 213 + 214 + <button disabled class="flex items-center gap-4 rounded-md px-4 py-3 text-left opacity-50"> 215 + <PasskeysOutlined size={24} class="shrink-0" /> 216 + 217 + <div class="min-w-0 grow"> 218 + <p class="text-base-300 font-medium">Passkey</p> 219 + <p class="text-base-300 text-neutral-foreground-3"> 220 + Use Face ID, Touch ID, or Windows Hello (coming soon) 221 + </p> 222 + </div> 223 + </button> 224 + </DialogContent> 225 + 226 + <DialogActions> 227 + <DialogClose> 228 + <Button>Cancel</Button> 229 + </DialogClose> 230 + </DialogActions> 231 + </DialogBody> 232 + </DialogSurface> 233 + </Dialog> 234 + </div> 235 + ); 236 + }; 237 + 238 + const RecoverySection = () => { 239 + const ctx = getAppContext(); 240 + const session = getSession(); 241 + 242 + const backupCodeCount = ctx.accountManager.getRecoveryCodeCount(session.did); 243 + 244 + return ( 245 + <div class="flex flex-col gap-2"> 246 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Recovery options</h4> 247 + 248 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 249 + <div class="flex items-center gap-4 px-4 py-3"> 250 + <div class="min-w-0 grow"> 251 + <p class="text-base-300 font-medium">Backup codes</p> 252 + <p class="text-base-300 text-neutral-foreground-3"> 253 + {backupCodeCount > 0 ? `${backupCodeCount} of 10 codes remaining` : 'No backup codes generated'} 254 + </p> 255 + </div> 256 + 257 + {backupCodeCount > 0 ? ( 258 + <Menu> 259 + <MenuTrigger> 260 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 261 + <DotGrid1x3HorizontalOutlined size={16} /> 262 + </button> 263 + </MenuTrigger> 264 + 265 + <MenuPopover> 266 + <MenuList> 267 + <MenuItem href={routes.account.security.recovery.show.href()}>View codes</MenuItem> 268 + <MenuItem href={routes.account.security.recovery.regenerate.href()}> 269 + Regenerate codes 270 + </MenuItem> 271 + 272 + <MenuDivider /> 273 + 274 + <MenuItem href={routes.account.security.recovery.remove.href()}>Remove all codes</MenuItem> 275 + </MenuList> 276 + </MenuPopover> 277 + </Menu> 278 + ) : ( 279 + <Button href={routes.account.security.recovery.show.href()}>Generate</Button> 280 + )} 281 + </div> 282 + </div> 283 + </div> 284 + ); 285 + };
+177
packages/danaus/src/web/controllers/account/security/recovery.tsx
··· 1 + import { redirect, type Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import { BaseLayout } from '#web/layouts/base.tsx'; 6 + import { getAppContext } from '#web/middlewares/app-context.ts'; 7 + import { getSession } from '#web/middlewares/session.ts'; 8 + import Button from '#web/primitives/button.tsx'; 9 + import DialogActions from '#web/primitives/dialog-actions.tsx'; 10 + import DialogBody from '#web/primitives/dialog-body.tsx'; 11 + import DialogContent from '#web/primitives/dialog-content.tsx'; 12 + import DialogTitle from '#web/primitives/dialog-title.tsx'; 13 + import { routes } from '#web/routes.ts'; 14 + 15 + import { deleteBackupCodesForm, generateBackupCodesForm } from './recovery/lib/forms'; 16 + 17 + export default { 18 + middleware: [], 19 + actions: { 20 + show({ url }) { 21 + const { accountManager } = getAppContext(); 22 + const session = getSession(); 23 + 24 + // require sudo mode 25 + if (!accountManager.isSessionElevated(session)) { 26 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 27 + } 28 + 29 + // generate codes if none exist 30 + let codes = accountManager.getRecoveryCodes(session.did); 31 + if (codes.length === 0) { 32 + accountManager.generateRecoveryCodes(session.did); 33 + codes = accountManager.getRecoveryCodes(session.did); 34 + } 35 + 36 + return render( 37 + <BaseLayout> 38 + <title>Recovery codes - Danaus</title> 39 + 40 + <div class="flex flex-1 items-center justify-center p-4"> 41 + <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 42 + <DialogBody> 43 + <DialogTitle>Recovery codes</DialogTitle> 44 + 45 + <DialogContent class="flex flex-col gap-4"> 46 + <p class="text-base-300"> 47 + Save these codes in a secure place. Each code can only be used once. 48 + </p> 49 + 50 + <div class="grid grid-cols-2 gap-2"> 51 + {codes.map((code) => ( 52 + <code class="rounded-md bg-neutral-background-3 px-3 py-2 text-center font-mono text-base-300 select-all"> 53 + {code} 54 + </code> 55 + ))} 56 + </div> 57 + </DialogContent> 58 + 59 + <DialogActions> 60 + <Button href={routes.account.security.overview.href()}>Done</Button> 61 + </DialogActions> 62 + </DialogBody> 63 + </div> 64 + </div> 65 + </BaseLayout>, 66 + ); 67 + }, 68 + regenerate: { 69 + middleware: [forms({ generateBackupCodesForm })], 70 + action({ url }) { 71 + const { accountManager } = getAppContext(); 72 + const session = getSession(); 73 + 74 + // require sudo mode 75 + if (!accountManager.isSessionElevated(session)) { 76 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 77 + } 78 + 79 + const { fields } = generateBackupCodesForm; 80 + 81 + const error = fields.allIssues()?.at(0); 82 + 83 + return render( 84 + <BaseLayout> 85 + <title>Regenerate recovery codes? - Danaus</title> 86 + 87 + <div class="flex flex-1 items-center justify-center p-4"> 88 + <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 89 + <form {...generateBackupCodesForm} class="contents"> 90 + <DialogBody> 91 + <DialogTitle>Regenerate recovery codes?</DialogTitle> 92 + 93 + <DialogContent> 94 + <p> 95 + This will invalidate your existing recovery codes. Make sure to save the new ones. 96 + </p> 97 + 98 + {error && ( 99 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 100 + {error.message} 101 + </p> 102 + )} 103 + </DialogContent> 104 + 105 + <DialogActions> 106 + <Button type="button" href={routes.account.security.overview.href()}> 107 + Cancel 108 + </Button> 109 + 110 + <Button type="submit" variant="primary"> 111 + Regenerate 112 + </Button> 113 + </DialogActions> 114 + </DialogBody> 115 + </form> 116 + </div> 117 + </div> 118 + </BaseLayout>, 119 + ); 120 + }, 121 + }, 122 + remove: { 123 + middleware: [forms({ deleteBackupCodesForm })], 124 + action({ url }) { 125 + const { accountManager } = getAppContext(); 126 + const session = getSession(); 127 + 128 + // require sudo mode 129 + if (!accountManager.isSessionElevated(session)) { 130 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 131 + } 132 + 133 + const { fields } = deleteBackupCodesForm; 134 + 135 + const error = fields.allIssues()?.at(0); 136 + 137 + return render( 138 + <BaseLayout> 139 + <title>Delete recovery codes? - Danaus</title> 140 + 141 + <div class="flex flex-1 items-center justify-center p-4"> 142 + <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 143 + <form {...deleteBackupCodesForm} class="contents"> 144 + <DialogBody> 145 + <DialogTitle>Delete recovery codes?</DialogTitle> 146 + 147 + <DialogContent> 148 + <p class="text-base-300"> 149 + You won't be able to use recovery codes to sign in until you generate new ones. 150 + </p> 151 + 152 + {error && ( 153 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 154 + {error.message} 155 + </p> 156 + )} 157 + </DialogContent> 158 + 159 + <DialogActions> 160 + <Button type="button" href={routes.account.security.overview.href()}> 161 + Cancel 162 + </Button> 163 + 164 + <Button type="submit" variant="primary"> 165 + Delete 166 + </Button> 167 + </DialogActions> 168 + </DialogBody> 169 + </form> 170 + </div> 171 + </div> 172 + </BaseLayout>, 173 + ); 174 + }, 175 + }, 176 + }, 177 + } satisfies Controller<typeof routes.account.security.recovery>;
+32
packages/danaus/src/web/controllers/account/security/recovery/lib/forms.ts
··· 1 + import { redirect } from '@oomfware/fetch-router'; 2 + import { form } from '@oomfware/forms'; 3 + 4 + import * as v from 'valibot'; 5 + 6 + import { requireSudo } from '#app/web/lib/forms.ts'; 7 + import { routes } from '#app/web/routes.ts'; 8 + 9 + import { getAppContext } from '#web/middlewares/app-context.ts'; 10 + import { getSession } from '#web/middlewares/session.ts'; 11 + 12 + export const generateBackupCodesForm = form(v.object({}), async () => { 13 + const { accountManager } = getAppContext(); 14 + const { did } = getSession(); 15 + 16 + requireSudo(); 17 + 18 + accountManager.generateRecoveryCodes(did); 19 + 20 + redirect(routes.account.security.recovery.show.href()); 21 + }); 22 + 23 + export const deleteBackupCodesForm = form(v.object({}), async () => { 24 + const { accountManager } = getAppContext(); 25 + const { did } = getSession(); 26 + 27 + requireSudo(); 28 + 29 + accountManager.deleteRecoveryCodes(did); 30 + 31 + redirect(routes.account.security.overview.href()); 32 + });
+223
packages/danaus/src/web/controllers/account/security/totp.tsx
··· 1 + import { redirect, type Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import { 6 + decodeSecret, 7 + encodeSecret, 8 + generateQrCode, 9 + generateSecret, 10 + generateTotpUri, 11 + } from '#app/accounts/totp.ts'; 12 + import { coerceToInteger } from '#app/web/lib/coerce.ts'; 13 + 14 + import { BaseLayout } from '#web/layouts/base.tsx'; 15 + import { getAppContext } from '#web/middlewares/app-context.ts'; 16 + import { getSession } from '#web/middlewares/session.ts'; 17 + import Button from '#web/primitives/button.tsx'; 18 + import DialogActions from '#web/primitives/dialog-actions.tsx'; 19 + import DialogBody from '#web/primitives/dialog-body.tsx'; 20 + import DialogContent from '#web/primitives/dialog-content.tsx'; 21 + import DialogTitle from '#web/primitives/dialog-title.tsx'; 22 + import Field from '#web/primitives/field.tsx'; 23 + import Input from '#web/primitives/input.tsx'; 24 + import { routes } from '#web/routes.ts'; 25 + 26 + import { removeTotpForm, setupTotpForm } from './totp/lib/forms'; 27 + 28 + export default { 29 + middleware: [], 30 + actions: { 31 + register: { 32 + middleware: [forms({ setupTotpForm })], 33 + async action({ url }) { 34 + const { accountManager, config } = getAppContext(); 35 + const session = getSession(); 36 + 37 + // require sudo mode 38 + if (!accountManager.isSessionElevated(session)) { 39 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 40 + } 41 + 42 + const account = accountManager.getAccount(session.did)!; 43 + 44 + const { fields } = setupTotpForm; 45 + 46 + // preserve secret across form submissions, or generate a new one 47 + let secretBase32 = fields.secret.value(); 48 + let secretBytes: Uint8Array; 49 + if (secretBase32) { 50 + secretBytes = decodeSecret(secretBase32); 51 + } else { 52 + secretBytes = generateSecret(); 53 + secretBase32 = encodeSecret(secretBytes); 54 + } 55 + 56 + const issuer = config.service.branding.name; 57 + const label = account.handle ?? session.did; 58 + 59 + const uri = generateTotpUri(secretBytes, label, issuer); 60 + 61 + const generalError = fields.issues()?.at(0); 62 + 63 + return render( 64 + <BaseLayout> 65 + <title>Set up authenticator - Danaus</title> 66 + 67 + <div class="flex flex-1 items-center justify-center p-4"> 68 + <div class="w-full max-w-150 rounded-xl bg-neutral-background-1 shadow-64"> 69 + <form {...setupTotpForm} class="contents"> 70 + <DialogBody> 71 + <DialogTitle>Set up authenticator app</DialogTitle> 72 + 73 + <DialogContent class="flex flex-col gap-4"> 74 + <p class="text-base-300"> 75 + Scan this QR code with your authenticator app, or enter the code manually. 76 + </p> 77 + 78 + <input {...fields.secret.as('hidden', secretBase32)} /> 79 + 80 + <div class="flex flex-col items-center gap-4 sm:flex-row sm:items-start"> 81 + <img 82 + src={await generateQrCode(uri)} 83 + alt="QR code for authenticator app" 84 + width={200} 85 + height={200} 86 + class="shrink-0 rounded-md" 87 + /> 88 + 89 + <div class="flex flex-col gap-4"> 90 + <div class="flex flex-col gap-2"> 91 + <span class="text-base-200 text-neutral-foreground-3">Manual entry code</span> 92 + <code class="rounded-md bg-neutral-background-3 px-3 py-2 font-mono text-base-300 break-all select-all"> 93 + {secretBase32} 94 + </code> 95 + </div> 96 + 97 + <div class="flex flex-col gap-2"> 98 + <span class="text-base-200 text-neutral-foreground-3">URI</span> 99 + <code class="rounded-md bg-neutral-background-3 px-3 py-2 font-mono text-base-300 break-all select-all"> 100 + {uri} 101 + </code> 102 + </div> 103 + </div> 104 + </div> 105 + 106 + <Field 107 + label="Name" 108 + hint="Give this authenticator a name to help you identify it" 109 + validationMessageText={fields.name.issues()?.at(0)?.message} 110 + > 111 + <Input 112 + {...fields.name.as('text')} 113 + placeholder={accountManager.generateTotpName(session.did)} 114 + /> 115 + </Field> 116 + 117 + <Field 118 + label="Verification code" 119 + required 120 + hint="Enter the 6-digit code from your authenticator app" 121 + validationMessageText={fields._code.issues()?.at(0)?.message} 122 + > 123 + <Input 124 + {...fields._code.as('text')} 125 + placeholder="000000" 126 + autocomplete="one-time-code" 127 + inputmode="numeric" 128 + pattern="[0-9]*" 129 + maxlength={6} 130 + required 131 + /> 132 + </Field> 133 + 134 + {generalError && ( 135 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 136 + {generalError.message} 137 + </p> 138 + )} 139 + </DialogContent> 140 + 141 + <DialogActions> 142 + <Button type="button" href={routes.account.security.overview.href()}> 143 + Cancel 144 + </Button> 145 + 146 + <Button type="submit" variant="primary"> 147 + Save 148 + </Button> 149 + </DialogActions> 150 + </DialogBody> 151 + </form> 152 + </div> 153 + </div> 154 + </BaseLayout>, 155 + ); 156 + }, 157 + }, 158 + remove: { 159 + middleware: [forms({ removeTotpForm })], 160 + action({ url, params }) { 161 + const { accountManager } = getAppContext(); 162 + const session = getSession(); 163 + 164 + const id = coerceToInteger(params.id); 165 + if (id === null) { 166 + redirect(routes.account.security.overview.href()); 167 + } 168 + 169 + const totp = accountManager.getTotpCredential(session.did, id); 170 + if (totp === null) { 171 + redirect(routes.account.security.overview.href()); 172 + } 173 + 174 + // require sudo mode 175 + if (!accountManager.isSessionElevated(session)) { 176 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 177 + } 178 + 179 + const { fields } = removeTotpForm; 180 + 181 + const error = fields.allIssues()?.at(0); 182 + 183 + return render( 184 + <BaseLayout> 185 + <title>Remove "{totp.name}" authenticator? - Danaus</title> 186 + 187 + <div class="flex flex-1 items-center justify-center p-4"> 188 + <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 189 + <form {...removeTotpForm} class="contents"> 190 + <input {...fields.id.as('hidden', params.id)} /> 191 + 192 + <DialogBody> 193 + <DialogTitle>Remove this authenticator?</DialogTitle> 194 + 195 + <DialogContent> 196 + <p class="text-base-300">You'll no longer be able to use "{totp.name}" to sign in.</p> 197 + 198 + {error && ( 199 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 200 + {error.message} 201 + </p> 202 + )} 203 + </DialogContent> 204 + 205 + <DialogActions> 206 + <Button type="button" href={routes.account.security.overview.href()}> 207 + Cancel 208 + </Button> 209 + 210 + <Button type="submit" variant="primary"> 211 + Remove 212 + </Button> 213 + </DialogActions> 214 + </DialogBody> 215 + </form> 216 + </div> 217 + </div> 218 + </BaseLayout>, 219 + ); 220 + }, 221 + }, 222 + }, 223 + } satisfies Controller<typeof routes.account.security.totp>;
+84
packages/danaus/src/web/controllers/account/security/totp/lib/forms.ts
··· 1 + import { XRPCError } from '@atcute/xrpc-server'; 2 + import { redirect } from '@oomfware/fetch-router'; 3 + import { form, invalid } from '@oomfware/forms'; 4 + 5 + import * as v from 'valibot'; 6 + 7 + import { decodeSecret, verifyTotpCode } from '#app/accounts/totp.ts'; 8 + import { requireSudo } from '#app/web/lib/forms.ts'; 9 + 10 + import { getAppContext } from '#web/middlewares/app-context.ts'; 11 + import { getSession } from '#web/middlewares/session.ts'; 12 + import { routes } from '#web/routes.ts'; 13 + 14 + /** 15 + * sets up a new TOTP credential after verifying the code. 16 + */ 17 + export const setupTotpForm = form( 18 + v.object({ 19 + name: v.optional(v.pipe(v.string(), v.maxLength(32, `Name is too long`))), 20 + secret: v.pipe(v.string(), v.minLength(1)), 21 + _code: v.pipe(v.string(), v.length(6, `Enter the 6-digit code`)), 22 + }), 23 + async (data, issue) => { 24 + const { accountManager } = getAppContext(); 25 + const { did } = getSession(); 26 + 27 + // verify the code against the provided secret 28 + let secretBytes: Uint8Array; 29 + try { 30 + secretBytes = decodeSecret(data.secret); 31 + } catch { 32 + invalid(issue._code(`Invalid setup, please try again`)); 33 + } 34 + 35 + const counter = await verifyTotpCode(secretBytes, data._code, null); 36 + if (counter === null) { 37 + invalid(issue._code(`Invalid code, please try again`)); 38 + } 39 + 40 + requireSudo(); 41 + 42 + // store the credential 43 + try { 44 + accountManager.createTotpCredential({ 45 + did: did, 46 + name: data.name, 47 + secret: secretBytes, 48 + lastUsedCounter: counter, 49 + }); 50 + } catch (err) { 51 + if (err instanceof XRPCError && err.status === 400) { 52 + switch (err.error) { 53 + case 'DuplicateTotpName': { 54 + invalid(issue.name(`An authenticator with this name already exists`)); 55 + } 56 + case 'TooManyTotpCredentials': { 57 + invalid(`You've reached the maximum number of authenticators allowed`); 58 + } 59 + } 60 + } 61 + throw err; 62 + } 63 + 64 + redirect(routes.account.security.overview.href()); 65 + }, 66 + ); 67 + 68 + /** 69 + * removes a TOTP credential. requires sudo mode. 70 + */ 71 + export const removeTotpForm = form( 72 + v.object({ 73 + id: v.pipe(v.string(), v.toNumber(), v.safeInteger()), 74 + }), 75 + async (data) => { 76 + const { accountManager } = getAppContext(); 77 + const { did } = getSession(); 78 + 79 + requireSudo(); 80 + accountManager.deleteTotpCredential(did, data.id); 81 + 82 + redirect(routes.account.security.overview.href()); 83 + }, 84 + );
+11 -46
packages/danaus/src/web/controllers/login.tsx
··· 1 - import type { BuildAction } from '@oomfware/fetch-router'; 2 - import { forms } from '@oomfware/forms'; 3 - import { render } from '@oomfware/jsx'; 1 + import type { Controller } from '@oomfware/fetch-router'; 4 2 5 - import { signInForm } from '../account/forms.ts'; 6 - import { BaseLayout } from '../layouts/base.tsx'; 7 - import Button from '../primitives/button.tsx'; 8 - import Field from '../primitives/field.tsx'; 9 - import Input from '../primitives/input.tsx'; 10 3 import type { routes } from '../routes.ts'; 11 4 5 + import mfa from './login/mfa.tsx'; 6 + import show from './login/show.tsx'; 7 + import sudo from './login/sudo.tsx'; 8 + 12 9 export default { 13 - middleware: [forms({ signInForm })], 14 - action() { 15 - const { fields } = signInForm; 16 - 17 - return render( 18 - <BaseLayout> 19 - <title>sign in - danaus</title> 20 - 21 - <div class="flex flex-1 items-center justify-center p-4"> 22 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 23 - <form {...signInForm} class="flex flex-col gap-6"> 24 - <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 25 - 26 - <Field 27 - label="Handle or email" 28 - required 29 - validationMessageText={fields.identifier.issues()?.[0]!.message} 30 - > 31 - <Input {...fields.identifier.as('text')} placeholder="alice.bsky.social" required autofocus /> 32 - </Field> 33 - 34 - <Field 35 - label="Password" 36 - required 37 - validationMessageText={fields._password.issues()?.[0]!.message} 38 - > 39 - <Input {...fields._password.as('password')} required /> 40 - </Field> 41 - 42 - <Button type="submit" variant="primary"> 43 - Sign in 44 - </Button> 45 - </form> 46 - </div> 47 - </div> 48 - </BaseLayout>, 49 - ); 10 + middleware: [], 11 + actions: { 12 + mfa: mfa, 13 + show: show, 14 + sudo: sudo, 50 15 }, 51 - } satisfies BuildAction<'ANY', typeof routes.home>; 16 + } satisfies Controller<typeof routes.login>;
+227
packages/danaus/src/web/controllers/login/lib/forms.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { XRPCError } from '@atcute/xrpc-server'; 3 + import { redirect } from '@oomfware/fetch-router'; 4 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 5 + import { form, invalid } from '@oomfware/forms'; 6 + 7 + import * as v from 'valibot'; 8 + 9 + import type { Account } from '#app/accounts/manager.ts'; 10 + import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 11 + import { isRecoveryCode, isTotpCode } from '#app/accounts/totp.ts'; 12 + import { setWebSessionToken } from '#app/auth/web.ts'; 13 + 14 + import { getAppContext } from '#web/middlewares/app-context.ts'; 15 + import { getSession } from '#web/middlewares/session.ts'; 16 + import { routes } from '#web/routes.ts'; 17 + 18 + export type AuthFactor = 'totp' | 'recovery' | 'password'; 19 + 20 + interface VerifyFactorOptions { 21 + did: Did; 22 + factor: AuthFactor; 23 + code: string; 24 + allowedFactors: AuthFactor[]; 25 + } 26 + 27 + /** 28 + * verifies an authentication factor (TOTP, backup code, or password). 29 + * calls invalid() on failure. 30 + * @param options verification options 31 + */ 32 + const verifyFactor = async (options: VerifyFactorOptions): Promise<void> => { 33 + const { accountManager } = getAppContext(); 34 + const { did, factor, code, allowedFactors } = options; 35 + 36 + if (!allowedFactors.includes(factor)) { 37 + invalid(`Invalid authentication method`); 38 + } 39 + 40 + switch (factor) { 41 + case 'totp': { 42 + if (!isTotpCode(code)) { 43 + invalid(`Invalid verification code`); 44 + } 45 + 46 + const valid = await accountManager.verifyAccountTotpCode(did, code); 47 + if (!valid) { 48 + invalid(`Invalid verification code`); 49 + } 50 + 51 + break; 52 + } 53 + case 'recovery': { 54 + if (!isRecoveryCode(code)) { 55 + invalid(`Invalid recovery code`); 56 + } 57 + 58 + const valid = accountManager.consumeRecoveryCode(did, code); 59 + if (!valid) { 60 + invalid(`Invalid recovery code`); 61 + } 62 + 63 + break; 64 + } 65 + case 'password': { 66 + if (code.length < MIN_PASSWORD_LENGTH || code.length > MAX_PASSWORD_LENGTH) { 67 + invalid(`Invalid password`); 68 + } 69 + 70 + try { 71 + const account = await accountManager.verifyAccountPassword(did, code); 72 + if (account === null) { 73 + invalid(`Invalid password`); 74 + } 75 + } catch (err) { 76 + if (err instanceof XRPCError && err.status === 400) { 77 + switch (err.error) { 78 + case 'InvalidPassword': { 79 + invalid(`Invalid password`); 80 + } 81 + } 82 + } 83 + 84 + throw err; 85 + } 86 + 87 + break; 88 + } 89 + default: { 90 + invalid(`Invalid authentication method`); 91 + } 92 + } 93 + }; 94 + 95 + export const loginForm = form( 96 + v.object({ 97 + identifier: v.pipe(v.string(), v.minLength(1, `Enter your email or username`)), 98 + _password: v.pipe(v.string(), v.minLength(1, `Enter your password`)), 99 + remember: v.optional(v.boolean()), 100 + redirect: v.pipe(v.string(), v.minLength(1)), 101 + }), 102 + async (data, issue) => { 103 + const { accountManager } = getAppContext(); 104 + const { request } = getContext(); 105 + 106 + if (data._password.length < MIN_PASSWORD_LENGTH || data._password.length > MAX_PASSWORD_LENGTH) { 107 + invalid(issue.identifier(`Invalid account credentials`)); 108 + } 109 + 110 + let account: Account | null; 111 + try { 112 + account = await accountManager.verifyAccountPassword(data.identifier, data._password); 113 + if (account === null) { 114 + invalid(issue.identifier(`Invalid account credentials`)); 115 + } 116 + } catch (err) { 117 + if (err instanceof XRPCError && err.status === 400) { 118 + switch (err.error) { 119 + case 'InvalidPassword': { 120 + invalid(issue.identifier(`Invalid account credentials`)); 121 + } 122 + } 123 + } 124 + 125 + throw err; 126 + } 127 + 128 + // clean up any expired MFA challenges 129 + accountManager.cleanupExpiredMfaChallenges(); 130 + 131 + // check if MFA is enabled 132 + if (accountManager.isMfaEnabled(account.did)) { 133 + // create MFA challenge and redirect 134 + const token = accountManager.createMfaChallenge(account.did); 135 + 136 + redirect(routes.login.mfa.index.href(undefined, { token, redirect: data.redirect })); 137 + } 138 + 139 + const { session, token } = await accountManager.createWebSession({ 140 + did: account.did, 141 + remember: data.remember ?? false, 142 + userAgent: request.headers.get('user-agent') ?? undefined, 143 + }); 144 + 145 + setWebSessionToken(request, token, { 146 + expires: session.expires_at, 147 + httpOnly: true, 148 + sameSite: 'lax', 149 + path: '/', 150 + }); 151 + 152 + redirect(data.redirect); 153 + }, 154 + ); 155 + 156 + export const verifyMfaLoginForm = form( 157 + v.object({ 158 + challenge: v.string(), 159 + factor: v.picklist<AuthFactor[]>(['totp', 'recovery']), 160 + _code: v.string(), 161 + remember: v.optional(v.boolean(), false), 162 + redirect: v.string(), 163 + }), 164 + async (data) => { 165 + const { accountManager } = getAppContext(); 166 + const { request } = getContext(); 167 + 168 + const challenge = accountManager.getMfaChallenge(data.challenge); 169 + if (challenge === null) { 170 + redirect(routes.login.show.href(undefined, { redirect: data.redirect })); 171 + } 172 + 173 + await verifyFactor({ 174 + did: challenge.did, 175 + factor: data.factor, 176 + code: data._code, 177 + allowedFactors: ['totp', 'recovery'], 178 + }); 179 + 180 + accountManager.deleteMfaChallenge(data.challenge); 181 + 182 + const { session, token } = await accountManager.createWebSession({ 183 + did: challenge.did, 184 + remember: data.remember ?? false, 185 + userAgent: request.headers.get('user-agent') ?? undefined, 186 + }); 187 + 188 + setWebSessionToken(request, token, { 189 + expires: session.expires_at, 190 + httpOnly: true, 191 + sameSite: 'lax', 192 + path: '/', 193 + }); 194 + 195 + redirect(data.redirect); 196 + }, 197 + ); 198 + 199 + const SUDO_ALLOWED_MFA_FACTORS: AuthFactor[] = ['totp', 'recovery']; 200 + const SUDO_ALLOWED_OFA_FACTORS: AuthFactor[] = ['password']; 201 + 202 + export const verifySudoForm = form( 203 + v.object({ 204 + factor: v.picklist<AuthFactor[]>(['totp', 'recovery', 'password']), 205 + _code: v.string(), 206 + redirect: v.pipe(v.string(), v.minLength(1)), 207 + }), 208 + async (data) => { 209 + const { accountManager } = getAppContext(); 210 + const session = getSession(); 211 + 212 + // determine allowed factors based on MFA status 213 + const hasMfa = accountManager.isMfaEnabled(session.did); 214 + const allowedFactors = hasMfa ? SUDO_ALLOWED_MFA_FACTORS : SUDO_ALLOWED_OFA_FACTORS; 215 + 216 + await verifyFactor({ 217 + did: session.did, 218 + factor: data.factor, 219 + code: data._code, 220 + allowedFactors, 221 + }); 222 + 223 + // elevate session and redirect 224 + accountManager.elevateSession(session.id); 225 + redirect(data.redirect); 226 + }, 227 + );
+184
packages/danaus/src/web/controllers/login/mfa.tsx
··· 1 + import { redirect, type Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render, type JSXNode } from '@oomfware/jsx'; 4 + 5 + import { 6 + RECOVERY_CODE_LENGTH, 7 + RECOVERY_CODE_RE, 8 + TOTP_CODE_LENGTH, 9 + TOTP_CODE_RE, 10 + } from '#app/accounts/totp.ts'; 11 + 12 + import { BaseLayout } from '#web/layouts/base.tsx'; 13 + import { getAppContext } from '#web/middlewares/app-context.ts'; 14 + import Button from '#web/primitives/button.tsx'; 15 + import Field from '#web/primitives/field.tsx'; 16 + import Input from '#web/primitives/input.tsx'; 17 + import MenuItem from '#web/primitives/menu-item.tsx'; 18 + import MenuList from '#web/primitives/menu-list.tsx'; 19 + import MenuPopover from '#web/primitives/menu-popover.tsx'; 20 + import MenuTrigger from '#web/primitives/menu-trigger.tsx'; 21 + import Menu from '#web/primitives/menu.tsx'; 22 + import { routes } from '#web/routes.ts'; 23 + 24 + import { verifyMfaLoginForm, type AuthFactor } from './lib/forms.ts'; 25 + 26 + export default { 27 + middleware: [forms({ verifyMfaLoginForm })], 28 + actions: { 29 + index({ url }) { 30 + const { accountManager } = getAppContext(); 31 + 32 + const redirectUrl = url.searchParams.get('redirect'); 33 + const challenge = url.searchParams.get('token'); 34 + if (challenge === null || accountManager.getMfaChallenge(challenge) === null) { 35 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 36 + } 37 + 38 + redirect(routes.login.mfa.totp.href(undefined, { token: challenge, redirect: redirectUrl })); 39 + }, 40 + totp({ url }) { 41 + const { accountManager } = getAppContext(); 42 + 43 + const { fields } = verifyMfaLoginForm; 44 + 45 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 46 + const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 47 + if (challenge == null || accountManager.getMfaChallenge(challenge) === null) { 48 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 49 + } 50 + 51 + return render( 52 + <BaseForm factor="totp" challenge={challenge} redirectUrl={redirectUrl}> 53 + <div class="flex flex-col gap-2"> 54 + <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 55 + <p class="text-base-300 text-neutral-foreground-3"> 56 + Enter the 6-digit code from your authenticator app. 57 + </p> 58 + </div> 59 + 60 + <Field label="Verification code" validationMessageText={fields.allIssues()?.at(0)?.message}> 61 + <Input 62 + {...fields._code.as('text')} 63 + placeholder="000000" 64 + autocomplete="one-time-code" 65 + inputmode="numeric" 66 + pattern={TOTP_CODE_RE.source} 67 + minlength={TOTP_CODE_LENGTH} 68 + maxlength={TOTP_CODE_LENGTH} 69 + required 70 + autofocus 71 + /> 72 + </Field> 73 + 74 + <Button type="submit" variant="primary"> 75 + Confirm 76 + </Button> 77 + </BaseForm>, 78 + ); 79 + }, 80 + webauthn() { 81 + redirect(routes.login.show.href()); 82 + }, 83 + recovery({ url }) { 84 + const { accountManager } = getAppContext(); 85 + 86 + const { fields } = verifyMfaLoginForm; 87 + 88 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 89 + const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 90 + if (challenge == null || accountManager.getMfaChallenge(challenge) === null) { 91 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 92 + } 93 + 94 + return render( 95 + <BaseForm factor="recovery" challenge={challenge} redirectUrl={redirectUrl}> 96 + <div class="flex flex-col gap-2"> 97 + <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 98 + <p class="text-base-300 text-neutral-foreground-3"> 99 + Enter one of your recovery codes to verify your identity. 100 + </p> 101 + </div> 102 + 103 + <Field label="Recovery code" validationMessageText={fields.allIssues()?.at(0)?.message}> 104 + <Input 105 + {...fields._code.as('text')} 106 + placeholder="XXXX-XXXX" 107 + pattern={RECOVERY_CODE_RE.source} 108 + minlength={RECOVERY_CODE_LENGTH} 109 + maxlength={RECOVERY_CODE_LENGTH + 1} 110 + required 111 + autofocus 112 + /> 113 + </Field> 114 + 115 + <Button type="submit" variant="primary"> 116 + Confirm 117 + </Button> 118 + </BaseForm>, 119 + ); 120 + }, 121 + }, 122 + } satisfies Controller<typeof routes.login.mfa>; 123 + 124 + const BaseForm = (props: { 125 + factor: AuthFactor; 126 + challenge: string; 127 + redirectUrl: string | undefined; 128 + children: JSXNode; 129 + }) => { 130 + const { fields } = verifyMfaLoginForm; 131 + 132 + const challenge = props.challenge; 133 + const redirectUrl = props.redirectUrl ?? routes.account.overview.href(); 134 + 135 + return ( 136 + <BaseLayout> 137 + <title>Two-factor authentication - Danaus</title> 138 + 139 + <div class="flex flex-1 items-center justify-center p-4"> 140 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 141 + <form {...verifyMfaLoginForm} class="flex flex-col gap-6"> 142 + <input {...fields.challenge.as('hidden', challenge)} /> 143 + <input {...fields.redirect.as('hidden', redirectUrl)} /> 144 + <input {...fields.factor.as('hidden', props.factor)} /> 145 + 146 + {props.children} 147 + 148 + <Menu> 149 + <MenuTrigger> 150 + <Button>Show other methods</Button> 151 + </MenuTrigger> 152 + 153 + <MenuPopover> 154 + <MenuList> 155 + {props.factor !== 'totp' && ( 156 + <MenuItem 157 + href={routes.login.mfa.totp.href(undefined, { 158 + token: challenge, 159 + redirect: redirectUrl, 160 + })} 161 + > 162 + Use authenticator app 163 + </MenuItem> 164 + )} 165 + 166 + {props.factor !== 'recovery' && ( 167 + <MenuItem 168 + href={routes.login.mfa.recovery.href(undefined, { 169 + token: challenge, 170 + redirect: redirectUrl, 171 + })} 172 + > 173 + Use 2FA recovery code 174 + </MenuItem> 175 + )} 176 + </MenuList> 177 + </MenuPopover> 178 + </Menu> 179 + </form> 180 + </div> 181 + </div> 182 + </BaseLayout> 183 + ); 184 + };
+62
packages/danaus/src/web/controllers/login/show.tsx
··· 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import { BaseLayout } from '#web/layouts/base.tsx'; 6 + import Button from '#web/primitives/button.tsx'; 7 + import Field from '#web/primitives/field.tsx'; 8 + import Input from '#web/primitives/input.tsx'; 9 + import { routes } from '#web/routes.ts'; 10 + 11 + import { loginForm } from './lib/forms'; 12 + 13 + export default { 14 + middleware: [forms({ loginForm })], 15 + action({ url }) { 16 + const { fields } = loginForm; 17 + 18 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 19 + 20 + return render( 21 + <BaseLayout> 22 + <title>Sign in - Danaus</title> 23 + 24 + <div class="flex flex-1 items-center justify-center p-4"> 25 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 26 + <form {...loginForm} class="flex flex-col gap-6"> 27 + <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 28 + 29 + <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 30 + 31 + <Field 32 + label="Handle or email" 33 + required 34 + validationMessageText={fields.identifier.issues()?.[0]!.message} 35 + > 36 + <Input 37 + {...fields.identifier.as('text')} 38 + autocomplete="username" 39 + placeholder="alice.bsky.social" 40 + required 41 + autofocus 42 + /> 43 + </Field> 44 + 45 + <Field 46 + label="Password" 47 + required 48 + validationMessageText={fields._password.issues()?.[0]!.message} 49 + > 50 + <Input {...fields._password.as('password')} autocomplete="current-password" required /> 51 + </Field> 52 + 53 + <Button type="submit" variant="primary"> 54 + Sign in 55 + </Button> 56 + </form> 57 + </div> 58 + </div> 59 + </BaseLayout>, 60 + ); 61 + }, 62 + } satisfies BuildAction<'ANY', typeof routes.login.show>;
+229
packages/danaus/src/web/controllers/login/sudo.tsx
··· 1 + import { redirect, type Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render, type JSXNode } from '@oomfware/jsx'; 4 + 5 + import { 6 + RECOVERY_CODE_LENGTH, 7 + RECOVERY_CODE_RE, 8 + TOTP_CODE_LENGTH, 9 + TOTP_CODE_RE, 10 + } from '#app/accounts/totp.ts'; 11 + 12 + import { BaseLayout } from '#web/layouts/base.tsx'; 13 + import { getAppContext } from '#web/middlewares/app-context.ts'; 14 + import { getSession, requireSession } from '#web/middlewares/session.ts'; 15 + import Button from '#web/primitives/button.tsx'; 16 + import Field from '#web/primitives/field.tsx'; 17 + import Input from '#web/primitives/input.tsx'; 18 + import MenuItem from '#web/primitives/menu-item.tsx'; 19 + import MenuList from '#web/primitives/menu-list.tsx'; 20 + import MenuPopover from '#web/primitives/menu-popover.tsx'; 21 + import MenuTrigger from '#web/primitives/menu-trigger.tsx'; 22 + import Menu from '#web/primitives/menu.tsx'; 23 + import { routes } from '#web/routes.ts'; 24 + 25 + import { verifySudoForm, type AuthFactor } from './lib/forms.ts'; 26 + 27 + export default { 28 + middleware: [requireSession(), forms({ verifySudoForm })], 29 + actions: { 30 + index({ url }) { 31 + const { accountManager } = getAppContext(); 32 + const session = getSession(); 33 + 34 + const redirectUrl = url.searchParams.get('redirect'); 35 + if (!redirectUrl) { 36 + redirect(routes.account.overview.href()); 37 + } 38 + 39 + const isElevated = accountManager.isSessionElevated(session); 40 + if (isElevated) { 41 + redirect(redirectUrl); 42 + } 43 + 44 + const hasMfa = accountManager.isMfaEnabled(session.did); 45 + if (hasMfa) { 46 + // TODO: redirect to preferred MFA 47 + redirect(routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })); 48 + } else { 49 + redirect(routes.login.sudo.password.href(undefined, { redirect: redirectUrl })); 50 + } 51 + }, 52 + totp({ url }) { 53 + const { accountManager } = getAppContext(); 54 + const session = getSession(); 55 + 56 + const { fields } = verifySudoForm; 57 + 58 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 59 + if (!redirectUrl) { 60 + redirect(routes.account.overview.href()); 61 + } 62 + 63 + if (!accountManager.isMfaEnabled(session.did)) { 64 + redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 65 + } 66 + 67 + return render( 68 + <BaseForm factor="totp" redirectUrl={redirectUrl}> 69 + <div class="flex flex-col gap-2"> 70 + <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 71 + <p class="text-base-300 text-neutral-foreground-3"> 72 + Enter the 6-digit code from your authenticator app to continue. 73 + </p> 74 + </div> 75 + 76 + <Field label="Verification code" validationMessageText={fields.allIssues()?.at(0)?.message}> 77 + <Input 78 + {...fields._code.as('text')} 79 + placeholder="000000" 80 + autocomplete="one-time-code" 81 + inputmode="numeric" 82 + pattern={TOTP_CODE_RE.source} 83 + minlength={TOTP_CODE_LENGTH} 84 + maxlength={TOTP_CODE_LENGTH} 85 + required 86 + autofocus 87 + /> 88 + </Field> 89 + 90 + <Button type="submit" variant="primary"> 91 + Confirm 92 + </Button> 93 + </BaseForm>, 94 + ); 95 + }, 96 + recovery({ url }) { 97 + const { accountManager } = getAppContext(); 98 + const session = getSession(); 99 + 100 + const { fields } = verifySudoForm; 101 + 102 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 103 + if (!redirectUrl) { 104 + redirect(routes.account.overview.href()); 105 + } 106 + 107 + if (!accountManager.isMfaEnabled(session.did)) { 108 + redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 109 + } 110 + 111 + return render( 112 + <BaseForm factor="recovery" redirectUrl={redirectUrl}> 113 + <div class="flex flex-col gap-2"> 114 + <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 115 + <p class="text-base-300 text-neutral-foreground-3"> 116 + Enter one of your recovery codes to continue. 117 + </p> 118 + </div> 119 + 120 + <Field label="Recovery code" validationMessageText={fields.allIssues()?.at(0)?.message}> 121 + <Input 122 + {...fields._code.as('text')} 123 + placeholder="XXXX-XXXX" 124 + pattern={RECOVERY_CODE_RE.source} 125 + minlength={RECOVERY_CODE_LENGTH} 126 + maxlength={RECOVERY_CODE_LENGTH + 1} 127 + required 128 + autofocus 129 + /> 130 + </Field> 131 + 132 + <Button type="submit" variant="primary"> 133 + Confirm 134 + </Button> 135 + </BaseForm>, 136 + ); 137 + }, 138 + password({ url }) { 139 + const { accountManager } = getAppContext(); 140 + const session = getSession(); 141 + 142 + const { fields } = verifySudoForm; 143 + 144 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 145 + if (!redirectUrl) { 146 + redirect(routes.account.overview.href()); 147 + } 148 + 149 + if (accountManager.isMfaEnabled(session.did)) { 150 + redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 151 + } 152 + 153 + return render( 154 + <BaseForm factor="password" redirectUrl={redirectUrl}> 155 + <div class="flex flex-col gap-2"> 156 + <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 157 + <p class="text-base-300 text-neutral-foreground-3">Enter your password to continue.</p> 158 + </div> 159 + 160 + <Field label="Password" validationMessageText={fields.allIssues()?.at(0)?.message}> 161 + <Input {...fields._code.as('password')} autocomplete="current-password" required autofocus /> 162 + </Field> 163 + 164 + <Button type="submit" variant="primary"> 165 + Confirm 166 + </Button> 167 + </BaseForm>, 168 + ); 169 + }, 170 + }, 171 + } satisfies Controller<typeof routes.login.sudo>; 172 + 173 + const BaseForm = (props: { factor: AuthFactor; redirectUrl: string; children: JSXNode }) => { 174 + const { accountManager } = getAppContext(); 175 + const { did } = getSession(); 176 + 177 + const { fields } = verifySudoForm; 178 + 179 + const hasMfa = accountManager.isMfaEnabled(did); 180 + 181 + return ( 182 + <BaseLayout> 183 + <title>Confirm your identity - Danaus</title> 184 + 185 + <div class="flex flex-1 items-center justify-center p-4"> 186 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 187 + <form {...verifySudoForm} class="flex flex-col gap-6"> 188 + <input {...fields.redirect.as('hidden', props.redirectUrl)} /> 189 + <input {...fields.factor.as('hidden', props.factor)} /> 190 + 191 + {props.children} 192 + 193 + {hasMfa && props.factor !== 'password' && ( 194 + <Menu> 195 + <MenuTrigger> 196 + <Button>Show other methods</Button> 197 + </MenuTrigger> 198 + 199 + <MenuPopover> 200 + <MenuList> 201 + {props.factor !== 'totp' && ( 202 + <MenuItem 203 + href={routes.login.sudo.totp.href(undefined, { 204 + redirect: props.redirectUrl, 205 + })} 206 + > 207 + Use authenticator app 208 + </MenuItem> 209 + )} 210 + 211 + {props.factor !== 'recovery' && ( 212 + <MenuItem 213 + href={routes.login.sudo.recovery.href(undefined, { 214 + redirect: props.redirectUrl, 215 + })} 216 + > 217 + Use 2FA recovery code 218 + </MenuItem> 219 + )} 220 + </MenuList> 221 + </MenuPopover> 222 + </Menu> 223 + )} 224 + </form> 225 + </div> 226 + </div> 227 + </BaseLayout> 228 + ); 229 + };
+1 -1
packages/danaus/src/web/layouts/account.tsx
··· 33 33 App passwords 34 34 </AsideItem> 35 35 36 - <AsideItem href={routes.account.security.href()} icon={<ShieldOutlined size={20} />}> 36 + <AsideItem href={routes.account.security.overview.href()} icon={<ShieldOutlined size={20} />}> 37 37 Security 38 38 </AsideItem> 39 39 </div>
+166
packages/danaus/src/web/lib/cache.ts
··· 1 + import { createInjectionKey, type Middleware } from '@oomfware/fetch-router'; 2 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + // #region cache node types 5 + 6 + const enum CacheStatus { 7 + UNTERMINATED = 0, 8 + TERMINATED = 1, 9 + ERRORED = 2, 10 + } 11 + 12 + type Primitive = string | number | null | undefined | symbol | boolean; 13 + 14 + type UnterminatedCacheNode<T> = { 15 + s: CacheStatus.UNTERMINATED; 16 + v: undefined; 17 + o: WeakMap<WeakKey, CacheNode<T>> | null; 18 + p: Map<Primitive, CacheNode<T>> | null; 19 + }; 20 + 21 + type TerminatedCacheNode<T> = { 22 + s: CacheStatus.TERMINATED; 23 + v: T; 24 + o: WeakMap<WeakKey, CacheNode<T>> | null; 25 + p: Map<Primitive, CacheNode<T>> | null; 26 + }; 27 + 28 + type ErroredCacheNode<T> = { 29 + s: CacheStatus.ERRORED; 30 + v: unknown; 31 + o: WeakMap<WeakKey, CacheNode<T>> | null; 32 + p: Map<Primitive, CacheNode<T>> | null; 33 + }; 34 + 35 + type CacheNode<T> = UnterminatedCacheNode<T> | TerminatedCacheNode<T> | ErroredCacheNode<T>; 36 + 37 + const createCacheNode = <T>(): CacheNode<T> => { 38 + return { 39 + s: CacheStatus.UNTERMINATED, 40 + v: undefined, 41 + o: null, 42 + p: null, 43 + }; 44 + }; 45 + 46 + // #endregion 47 + 48 + // #region cache store 49 + 50 + type CacheStore = WeakMap<WeakKey, CacheNode<unknown>>; 51 + 52 + const cacheStoreKey = createInjectionKey<CacheStore>(); 53 + 54 + /** 55 + * middleware that provides a cache store scoped to the current request. 56 + */ 57 + export const provideCache = (): Middleware => { 58 + return async ({ store }, next) => { 59 + store.provide(cacheStoreKey, new WeakMap()); 60 + return next(); 61 + }; 62 + }; 63 + 64 + const getCacheStore = (): CacheStore | undefined => { 65 + try { 66 + return getContext().store.inject(cacheStoreKey); 67 + } catch { 68 + return undefined; 69 + } 70 + }; 71 + 72 + // #endregion 73 + 74 + // #region cache function 75 + 76 + /** 77 + * wraps a function to memoize its results for the duration of the current request. 78 + * arguments are compared by identity (===) with special handling for objects via WeakMap. 79 + * @param fn the function to memoize 80 + * @returns a memoized version of the function 81 + */ 82 + export const cache = <A extends unknown[], T>(fn: (...args: A) => T): ((...args: A) => T) => { 83 + return function (this: unknown, ...args: A): T { 84 + const store = getCacheStore(); 85 + if (!store) { 86 + return fn.apply(this, args); 87 + } 88 + 89 + let cacheNode: CacheNode<T>; 90 + const fnNode = store.get(fn) as CacheNode<T> | undefined; 91 + if (fnNode === undefined) { 92 + cacheNode = createCacheNode(); 93 + store.set(fn, cacheNode); 94 + } else { 95 + cacheNode = fnNode; 96 + } 97 + 98 + // walk through arguments to find/create the cache node 99 + for (let i = 0; i < args.length; i++) { 100 + const arg = args[i]; 101 + 102 + if (typeof arg === 'function' || (typeof arg === 'object' && arg !== null)) { 103 + // objects go into a WeakMap 104 + let objectCache = cacheNode.o; 105 + if (objectCache === null) { 106 + cacheNode.o = objectCache = new WeakMap(); 107 + } 108 + 109 + const objectNode = objectCache.get(arg); 110 + if (objectNode === undefined) { 111 + cacheNode = createCacheNode(); 112 + objectCache.set(arg, cacheNode); 113 + } else { 114 + cacheNode = objectNode; 115 + } 116 + } else { 117 + // primitives go into a regular Map 118 + let primitiveCache = cacheNode.p; 119 + if (primitiveCache === null) { 120 + cacheNode.p = primitiveCache = new Map(); 121 + } 122 + 123 + const primitiveNode = primitiveCache.get(arg as Primitive); 124 + if (primitiveNode === undefined) { 125 + cacheNode = createCacheNode(); 126 + primitiveCache.set(arg as Primitive, cacheNode); 127 + } else { 128 + cacheNode = primitiveNode; 129 + } 130 + } 131 + } 132 + 133 + // return cached value or compute 134 + if (cacheNode.s === CacheStatus.TERMINATED) { 135 + return cacheNode.v; 136 + } 137 + if (cacheNode.s === CacheStatus.ERRORED) { 138 + throw cacheNode.v; 139 + } 140 + 141 + try { 142 + const result = fn.apply(this, args); 143 + 144 + const terminatedNode = cacheNode as unknown as TerminatedCacheNode<T>; 145 + terminatedNode.s = CacheStatus.TERMINATED; 146 + terminatedNode.v = result; 147 + 148 + return result; 149 + } catch (error) { 150 + const erroredNode = cacheNode as unknown as ErroredCacheNode<T>; 151 + erroredNode.s = CacheStatus.ERRORED; 152 + erroredNode.v = error; 153 + 154 + throw error; 155 + } 156 + }; 157 + }; 158 + 159 + cache.decorate = <This, A extends unknown[], T>( 160 + target: (this: This, ...args: A) => T, 161 + _context: ClassMethodDecoratorContext, 162 + ) => { 163 + return cache(target); 164 + }; 165 + 166 + // #endregion
+8
packages/danaus/src/web/lib/coerce.ts
··· 1 + export const coerceToInteger = (input: string): number | null => { 2 + const val = +input; 3 + if (!Number.isSafeInteger(val) || val < 0) { 4 + return null; 5 + } 6 + 7 + return val; 8 + };
+21
packages/danaus/src/web/lib/forms.ts
··· 1 + import { invalid } from '@oomfware/forms'; 2 + 3 + import { getAppContext } from '../middlewares/app-context'; 4 + import { getSession } from '../middlewares/session'; 5 + 6 + /** 7 + * require the current session to be in sudo mode. 8 + * refreshes the sudo timeout on success. 9 + * calls invalid() if not elevated. 10 + */ 11 + export const requireSudo = (): void => { 12 + const { accountManager } = getAppContext(); 13 + const session = getSession(); 14 + 15 + if (!accountManager.isSessionElevated(session)) { 16 + invalid(`Elevated permission has expired, reauthenticate again.`); 17 + } 18 + 19 + // refresh the sudo timeout 20 + accountManager.elevateSession(session.id); 21 + };
+11 -6
packages/danaus/src/web/middlewares/session.ts
··· 4 4 import type { WebSession } from '#app/accounts/manager.ts'; 5 5 import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 6 6 7 + import { routes } from '../routes.ts'; 8 + 7 9 import { getAppContext } from './app-context.ts'; 8 10 9 11 const sessionKey = createInjectionKey<WebSession>(); ··· 14 16 */ 15 17 export const requireSession = (): Middleware => { 16 18 return async ({ request, url, store }, next) => { 17 - const ctx = getAppContext(); 19 + const { accountManager, config } = getAppContext(); 18 20 const path = url.pathname; 19 21 22 + const redirectUrl = routes.login.show.href(undefined, { redirect: path }); 23 + 20 24 const token = readWebSessionToken(request); 21 25 if (!token) { 22 - redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 26 + redirect(redirectUrl); 23 27 } 24 28 25 - const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 29 + const sessionId = verifyWebSessionToken(config.secrets.jwtKey, token); 26 30 if (!sessionId) { 27 - redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 31 + redirect(redirectUrl); 28 32 } 29 33 30 - const session = ctx.accountManager.getWebSession(sessionId); 34 + const session = accountManager.getWebSession(sessionId); 31 35 if (!session) { 32 - redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 36 + redirect(redirectUrl); 33 37 } 34 38 35 39 store.provide(sessionKey, session); ··· 47 51 if (!session) { 48 52 throw new Error('Session not found in request context'); 49 53 } 54 + 50 55 return session; 51 56 };
+1 -1
packages/danaus/src/web/primitives/dialog-body.tsx
··· 3 3 import { cva } from 'cva'; 4 4 5 5 const root = cva({ 6 - base: ['grid gap-2', '@container/dialog-body'], 6 + base: ['grid gap-2 p-6', '@container/dialog-body'], 7 7 }); 8 8 9 9 export interface DialogBodyProps {
-1
packages/danaus/src/web/primitives/dialog-surface.tsx
··· 31 31 'box-border', 32 32 'w-full', 33 33 'max-h-[calc(100dvh-48px)]', 34 - 'p-6', 35 34 // rounded top on mobile, all corners on larger screens 36 35 'rounded-t-xl sm:rounded-xl', 37 36 'bg-neutral-background-1 text-neutral-foreground-1',
+12
packages/danaus/src/web/primitives/input.tsx
··· 67 67 disabled?: boolean; 68 68 autofocus?: boolean; 69 69 autocomplete?: string; 70 + inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; 71 + pattern?: string; 72 + minlength?: number; 73 + maxlength?: number; 70 74 required?: boolean; 71 75 contentBefore?: JSXNode; 72 76 contentAfter?: JSXNode; ··· 82 86 disabled = false, 83 87 autofocus = false, 84 88 autocomplete, 89 + inputmode, 90 + pattern, 91 + minlength, 92 + maxlength, 85 93 required, 86 94 contentBefore, 87 95 contentAfter, ··· 111 119 placeholder={placeholder} 112 120 autofocus={autofocus} 113 121 autocomplete={autocomplete} 122 + inputmode={inputmode} 123 + pattern={pattern} 124 + minlength={minlength} 125 + maxlength={maxlength} 114 126 required={required ?? fieldContext?.required} 115 127 aria-describedby={ariaDescribedBy} 116 128 aria-invalid={fieldContext?.validationStatus === 'error' ? true : undefined}
+30 -3
packages/danaus/src/web/routes.ts
··· 11 11 }, 12 12 }, 13 13 14 - // login is separate - no session required 15 - login: '/account/login', 14 + // login routes 15 + login: { 16 + show: '/account/login', 17 + mfa: { 18 + index: '/account/login/mfa', 19 + totp: '/account/login/mfa/totp', 20 + webauthn: '/account/login/mfa/webauthn', 21 + recovery: '/account/login/mfa/recovery', 22 + }, 23 + 24 + // sudo is located here just so we can share some parts with the MFA page 25 + sudo: { 26 + index: '/account/sudo', 27 + totp: '/account/sudo/totp', 28 + recovery: '/account/sudo/recovery', 29 + password: '/account/sudo/password', 30 + }, 31 + }, 16 32 17 33 // account routes - all require session 18 34 account: { 19 35 overview: '/account', 20 36 appPasswords: '/account/app-passwords', 21 - security: '/account/security', 37 + security: { 38 + overview: '/account/security', 39 + totp: { 40 + register: '/account/security/totp/register', 41 + remove: '/account/security/totp/:id/remove', 42 + }, 43 + recovery: { 44 + show: '/account/security/recovery', 45 + regenerate: '/account/security/recovery/regenerate', 46 + remove: '/account/security/recovery/remove', 47 + }, 48 + }, 22 49 }, 23 50 24 51 oauth: {
+20
packages/danaus/src/web/styles/main.out.css
··· 877 877 .wrap-break-word { 878 878 overflow-wrap: break-word; 879 879 } 880 + .break-all { 881 + word-break: break-all; 882 + } 880 883 .text-compound-brand-foreground-1 { 881 884 color: var(--color-compound-brand-foreground-1); 882 885 } ··· 934 937 .opacity-0 { 935 938 opacity: 0%; 936 939 } 940 + .opacity-50 { 941 + opacity: 50%; 942 + } 937 943 .shadow-4 { 938 944 --tw-shadow: var(--shadow-4); 939 945 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); ··· 976 982 .popover-slide-down { 977 983 --_slide-x: 0; 978 984 --_slide-y: -8px; 985 + } 986 + .select-all { 987 + -webkit-user-select: all; 988 + user-select: all; 979 989 } 980 990 .select-none { 981 991 -webkit-user-select: none; ··· 1430 1440 grid-template-columns: repeat(4, minmax(0, 1fr)); 1431 1441 } 1432 1442 } 1443 + .sm\:flex-row { 1444 + @media (width >= 40rem) { 1445 + flex-direction: row; 1446 + } 1447 + } 1433 1448 .sm\:items-center { 1434 1449 @media (width >= 40rem) { 1435 1450 align-items: center; 1451 + } 1452 + } 1453 + .sm\:items-start { 1454 + @media (width >= 40rem) { 1455 + align-items: flex-start; 1436 1456 } 1437 1457 } 1438 1458 .sm\:rounded-xl {
+163 -5
pnpm-lock.yaml
··· 101 101 specifier: ^0.2.1 102 102 version: 0.2.1 103 103 '@oomfware/forms': 104 - specifier: ^0.2.0 105 - version: 0.2.0(@oomfware/fetch-router@0.2.1) 104 + specifier: ^0.2.2 105 + version: 0.2.2(@oomfware/fetch-router@0.2.1) 106 106 '@oomfware/jsx': 107 107 specifier: ^0.1.4 108 108 version: 0.1.4 ··· 124 124 p-queue: 125 125 specifier: ^9.1.0 126 126 version: 9.1.0 127 + qrcode: 128 + specifier: ^1.5.4 129 + version: 1.5.4 127 130 valibot: 128 131 specifier: ^1.2.0 129 132 version: 1.2.0(typescript@5.9.3) ··· 140 143 '@types/bun': 141 144 specifier: ^1.3.5 142 145 version: 1.3.5 146 + '@types/qrcode': 147 + specifier: ^1.5.6 148 + version: 1.5.6 143 149 concurrently: 144 150 specifier: ^9.2.1 145 151 version: 9.2.1 ··· 887 893 '@oomfware/fetch-router@0.2.1': 888 894 resolution: {integrity: sha512-WV0cSeKjyTmM2pXYlRzv1md3Dym1vMR8PnJ/GfZUg8i1GS7RIDezmMkqVaWI/9IpeOHhs+QeDO41q1u+z1EzSg==} 889 895 890 - '@oomfware/forms@0.2.0': 891 - resolution: {integrity: sha512-XNvTZzAAur4ahitZ5R5VSZSzJem9Myn1T5Vhv6RLhVALn8qTsOKD8ju+hYed9P/cdMGog4DhKpiyaXbS5Elicw==} 896 + '@oomfware/forms@0.2.2': 897 + resolution: {integrity: sha512-NEwj0jk8EC5GKAGq8J5GAxbm1rKdvdJkTxER/RGAdW0pCInoyN3gZiSHTuQisJRgoz0DjzF2tor8g6Hb8+em6w==} 892 898 peerDependencies: 893 899 '@oomfware/fetch-router': ^0.2.1 894 900 ··· 1534 1540 '@types/node@22.19.3': 1535 1541 resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} 1536 1542 1543 + '@types/qrcode@1.5.6': 1544 + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} 1545 + 1537 1546 '@types/readable-stream@4.0.23': 1538 1547 resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} 1539 1548 ··· 1649 1658 call-bound@1.0.4: 1650 1659 resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} 1651 1660 engines: {node: '>= 0.4'} 1661 + 1662 + camelcase@5.3.1: 1663 + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} 1664 + engines: {node: '>=6'} 1652 1665 1653 1666 cborg@1.10.2: 1654 1667 resolution: {integrity: sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==} ··· 1658 1671 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1659 1672 engines: {node: '>=10'} 1660 1673 1674 + cliui@6.0.0: 1675 + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} 1676 + 1661 1677 cliui@8.0.1: 1662 1678 resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} 1663 1679 engines: {node: '>=12'} ··· 1756 1772 supports-color: 1757 1773 optional: true 1758 1774 1775 + decamelize@1.2.0: 1776 + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} 1777 + engines: {node: '>=0.10.0'} 1778 + 1759 1779 default-browser-id@5.0.1: 1760 1780 resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} 1761 1781 engines: {node: '>=18'} ··· 1796 1816 detect-libc@2.1.2: 1797 1817 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 1798 1818 engines: {node: '>=8'} 1819 + 1820 + dijkstrajs@1.0.3: 1821 + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} 1799 1822 1800 1823 disposable-email-domains-js@1.20.0: 1801 1824 resolution: {integrity: sha512-Dfj1ZM13ZbifEi39zHXQyoFLBfMjlF1TIspdz4BDxhoiZTYckQkGBVGQM5NyGAGio0ty0MZlZgI7AFjPCmEfVg==} ··· 2018 2041 resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} 2019 2042 engines: {node: '>= 0.8'} 2020 2043 2044 + find-up@4.1.0: 2045 + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} 2046 + engines: {node: '>=8'} 2047 + 2021 2048 follow-redirects@1.15.11: 2022 2049 resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} 2023 2050 engines: {node: '>=4.0'} ··· 2296 2323 resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} 2297 2324 engines: {node: '>= 12.0.0'} 2298 2325 2326 + locate-path@5.0.0: 2327 + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} 2328 + engines: {node: '>=8'} 2329 + 2299 2330 lodash.camelcase@4.3.0: 2300 2331 resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} 2301 2332 ··· 2480 2511 resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} 2481 2512 engines: {node: '>=4'} 2482 2513 2514 + p-limit@2.3.0: 2515 + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} 2516 + engines: {node: '>=6'} 2517 + 2518 + p-locate@4.1.0: 2519 + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} 2520 + engines: {node: '>=8'} 2521 + 2483 2522 p-queue@6.6.2: 2484 2523 resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} 2485 2524 engines: {node: '>=8'} ··· 2496 2535 resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} 2497 2536 engines: {node: '>=20'} 2498 2537 2538 + p-try@2.2.0: 2539 + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} 2540 + engines: {node: '>=6'} 2541 + 2499 2542 p-wait-for@3.2.0: 2500 2543 resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} 2501 2544 engines: {node: '>=8'} ··· 2504 2547 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 2505 2548 engines: {node: '>= 0.8'} 2506 2549 2550 + path-exists@4.0.0: 2551 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 2552 + engines: {node: '>=8'} 2553 + 2507 2554 path-to-regexp@0.1.12: 2508 2555 resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} 2509 2556 ··· 2564 2611 pirates@4.0.7: 2565 2612 resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} 2566 2613 engines: {node: '>= 6'} 2614 + 2615 + pngjs@5.0.0: 2616 + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} 2617 + engines: {node: '>=10.13.0'} 2567 2618 2568 2619 postcss@8.5.6: 2569 2620 resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} ··· 2663 2714 proxy-from-env@1.1.0: 2664 2715 resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} 2665 2716 2717 + qrcode@1.5.4: 2718 + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} 2719 + engines: {node: '>=10.13.0'} 2720 + hasBin: true 2721 + 2666 2722 qs@6.14.1: 2667 2723 resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} 2668 2724 engines: {node: '>=0.6'} ··· 2700 2756 require-directory@2.1.1: 2701 2757 resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} 2702 2758 engines: {node: '>=0.10.0'} 2759 + 2760 + require-main-filename@2.0.0: 2761 + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} 2703 2762 2704 2763 resolve-pkg-maps@1.0.0: 2705 2764 resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} ··· 2747 2806 serve-static@1.16.3: 2748 2807 resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} 2749 2808 engines: {node: '>= 0.8.0'} 2809 + 2810 + set-blocking@2.0.0: 2811 + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} 2750 2812 2751 2813 setprototypeof@1.2.0: 2752 2814 resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} ··· 2946 3008 whatwg-url@5.0.0: 2947 3009 resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} 2948 3010 3011 + which-module@2.0.1: 3012 + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} 3013 + 3014 + wrap-ansi@6.2.0: 3015 + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} 3016 + engines: {node: '>=8'} 3017 + 2949 3018 wrap-ansi@7.0.0: 2950 3019 resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 2951 3020 engines: {node: '>=10'} ··· 2970 3039 resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} 2971 3040 engines: {node: '>=0.4'} 2972 3041 3042 + y18n@4.0.3: 3043 + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} 3044 + 2973 3045 y18n@5.0.8: 2974 3046 resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} 2975 3047 engines: {node: '>=10'} 3048 + 3049 + yargs-parser@18.1.3: 3050 + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} 3051 + engines: {node: '>=6'} 2976 3052 2977 3053 yargs-parser@21.1.1: 2978 3054 resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} 2979 3055 engines: {node: '>=12'} 2980 3056 3057 + yargs@15.4.1: 3058 + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} 3059 + engines: {node: '>=8'} 3060 + 2981 3061 yargs@17.7.2: 2982 3062 resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} 2983 3063 engines: {node: '>=12'} ··· 3906 3986 dependencies: 3907 3987 '@remix-run/route-pattern': 0.16.0 3908 3988 3909 - '@oomfware/forms@0.2.0(@oomfware/fetch-router@0.2.1)': 3989 + '@oomfware/forms@0.2.2(@oomfware/fetch-router@0.2.1)': 3910 3990 dependencies: 3911 3991 '@oomfware/fetch-router': 0.2.1 3912 3992 '@standard-schema/spec': 1.1.0 ··· 4379 4459 dependencies: 4380 4460 undici-types: 6.21.0 4381 4461 4462 + '@types/qrcode@1.5.6': 4463 + dependencies: 4464 + '@types/node': 22.19.3 4465 + 4382 4466 '@types/readable-stream@4.0.23': 4383 4467 dependencies: 4384 4468 '@types/node': 22.19.3 ··· 4535 4619 call-bind-apply-helpers: 1.0.2 4536 4620 get-intrinsic: 1.3.0 4537 4621 4622 + camelcase@5.3.1: {} 4623 + 4538 4624 cborg@1.10.2: {} 4539 4625 4540 4626 chalk@4.1.2: 4541 4627 dependencies: 4542 4628 ansi-styles: 4.3.0 4543 4629 supports-color: 7.2.0 4630 + 4631 + cliui@6.0.0: 4632 + dependencies: 4633 + string-width: 4.2.3 4634 + strip-ansi: 6.0.1 4635 + wrap-ansi: 6.2.0 4544 4636 4545 4637 cliui@8.0.1: 4546 4638 dependencies: ··· 4632 4724 dependencies: 4633 4725 ms: 2.1.3 4634 4726 4727 + decamelize@1.2.0: {} 4728 + 4635 4729 default-browser-id@5.0.1: {} 4636 4730 4637 4731 default-browser@5.4.0: ··· 4655 4749 4656 4750 detect-libc@2.1.2: {} 4657 4751 4752 + dijkstrajs@1.0.3: {} 4753 + 4658 4754 disposable-email-domains-js@1.20.0: {} 4659 4755 4660 4756 drizzle-kit@1.0.0-beta.6-4414a19: ··· 4844 4940 unpipe: 1.0.0 4845 4941 transitivePeerDependencies: 4846 4942 - supports-color 4943 + 4944 + find-up@4.1.0: 4945 + dependencies: 4946 + locate-path: 5.0.0 4947 + path-exists: 4.0.0 4847 4948 4848 4949 follow-redirects@1.15.11: {} 4849 4950 ··· 5109 5210 lightningcss-win32-arm64-msvc: 1.30.2 5110 5211 lightningcss-win32-x64-msvc: 1.30.2 5111 5212 5213 + locate-path@5.0.0: 5214 + dependencies: 5215 + p-locate: 4.1.0 5216 + 5112 5217 lodash.camelcase@4.3.0: {} 5113 5218 5114 5219 lodash.defaults@4.2.0: {} ··· 5279 5384 5280 5385 p-finally@1.0.0: {} 5281 5386 5387 + p-limit@2.3.0: 5388 + dependencies: 5389 + p-try: 2.2.0 5390 + 5391 + p-locate@4.1.0: 5392 + dependencies: 5393 + p-limit: 2.3.0 5394 + 5282 5395 p-queue@6.6.2: 5283 5396 dependencies: 5284 5397 eventemitter3: 4.0.7 ··· 5295 5408 5296 5409 p-timeout@7.0.1: {} 5297 5410 5411 + p-try@2.2.0: {} 5412 + 5298 5413 p-wait-for@3.2.0: 5299 5414 dependencies: 5300 5415 p-timeout: 3.2.0 5301 5416 5302 5417 parseurl@1.3.3: {} 5303 5418 5419 + path-exists@4.0.0: {} 5420 + 5304 5421 path-to-regexp@0.1.12: {} 5305 5422 5306 5423 pg-cloudflare@1.2.7: ··· 5372 5489 5373 5490 pirates@4.0.7: {} 5374 5491 5492 + pngjs@5.0.0: {} 5493 + 5375 5494 postcss@8.5.6: 5376 5495 dependencies: 5377 5496 nanoid: 3.3.11 ··· 5424 5543 5425 5544 proxy-from-env@1.1.0: {} 5426 5545 5546 + qrcode@1.5.4: 5547 + dependencies: 5548 + dijkstrajs: 1.0.3 5549 + pngjs: 5.0.0 5550 + yargs: 15.4.1 5551 + 5427 5552 qs@6.14.1: 5428 5553 dependencies: 5429 5554 side-channel: 1.1.0 ··· 5458 5583 redis-errors: 1.2.0 5459 5584 5460 5585 require-directory@2.1.1: {} 5586 + 5587 + require-main-filename@2.0.0: {} 5461 5588 5462 5589 resolve-pkg-maps@1.0.0: {} 5463 5590 ··· 5515 5642 transitivePeerDependencies: 5516 5643 - supports-color 5517 5644 5645 + set-blocking@2.0.0: {} 5646 + 5518 5647 setprototypeof@1.2.0: {} 5519 5648 5520 5649 sharp@0.33.5: ··· 5734 5863 dependencies: 5735 5864 tr46: 0.0.3 5736 5865 webidl-conversions: 3.0.1 5866 + 5867 + which-module@2.0.1: {} 5868 + 5869 + wrap-ansi@6.2.0: 5870 + dependencies: 5871 + ansi-styles: 4.3.0 5872 + string-width: 4.2.3 5873 + strip-ansi: 6.0.1 5737 5874 5738 5875 wrap-ansi@7.0.0: 5739 5876 dependencies: ··· 5749 5886 5750 5887 xtend@4.0.2: {} 5751 5888 5889 + y18n@4.0.3: {} 5890 + 5752 5891 y18n@5.0.8: {} 5753 5892 5893 + yargs-parser@18.1.3: 5894 + dependencies: 5895 + camelcase: 5.3.1 5896 + decamelize: 1.2.0 5897 + 5754 5898 yargs-parser@21.1.1: {} 5899 + 5900 + yargs@15.4.1: 5901 + dependencies: 5902 + cliui: 6.0.0 5903 + decamelize: 1.2.0 5904 + find-up: 4.1.0 5905 + get-caller-file: 2.0.5 5906 + require-directory: 2.1.1 5907 + require-main-filename: 2.0.0 5908 + set-blocking: 2.0.0 5909 + string-width: 4.2.3 5910 + which-module: 2.0.1 5911 + y18n: 4.0.3 5912 + yargs-parser: 18.1.3 5755 5913 5756 5914 yargs@17.7.2: 5757 5915 dependencies: