mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 18:04:45 +10:00
Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#09090B" />
|
||||
<meta name="application-name" content="Reactive Resume" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.">
|
||||
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
|
||||
<title>Reactive Resume</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-250
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-251
File diff suppressed because it is too large
Load Diff
+242
-250
File diff suppressed because it is too large
Load Diff
+13
-21
@@ -4,10 +4,10 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rm -rf .output && vite build",
|
||||
"build": "rm -rf dist && vite build",
|
||||
"dev": "vite dev",
|
||||
"serve": "vite preview",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"start": "vite preview",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -16,7 +16,7 @@
|
||||
"lingui:extract": "lingui extract --clean --overwrite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.186",
|
||||
"@ai-sdk/react": "^3.0.187",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.8",
|
||||
@@ -27,33 +27,27 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lingui/core": "^6.0.1",
|
||||
"@lingui/react": "^6.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/json-schema": "^1.14.3",
|
||||
"@orpc/openapi": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
"@orpc/tanstack-query": "^1.14.3",
|
||||
"@orpc/zod": "^1.14.3",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/docx": "workspace:*",
|
||||
"@reactive-resume/fonts": "workspace:*",
|
||||
"@reactive-resume/import": "workspace:*",
|
||||
"@reactive-resume/pdf": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.0",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-router": "^1.170.4",
|
||||
"@tanstack/react-router-ssr-query": "^1.167.0",
|
||||
"@tanstack/react-start": "^1.168.6",
|
||||
"@tiptap/extension-color": "^3.23.4",
|
||||
"@tiptap/extension-highlight": "^3.23.4",
|
||||
"@tiptap/extension-table": "^3.23.4",
|
||||
@@ -65,17 +59,17 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.10.1",
|
||||
"ai": "^6.0.184",
|
||||
"ai": "^6.0.185",
|
||||
"better-auth": "1.6.11",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"immer": "^11.1.8",
|
||||
"js-cookie": "^3.0.7",
|
||||
"motion": "^12.38.0",
|
||||
"motion": "^12.39.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"pg": "^8.20.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
@@ -84,7 +78,6 @@
|
||||
"react-window": "^2.2.7",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"sonner": "^2.0.7",
|
||||
"srvx": "^0.11.15",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zod": "^4.4.3",
|
||||
@@ -97,15 +90,14 @@
|
||||
"@lingui/vite-plugin": "^6.0.1",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.168.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260517.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"nitro": "3.0.260429-beta",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.13",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { definePlugin } from "nitro";
|
||||
import { Pool } from "pg";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
function resolveMigrationsFolder(): string {
|
||||
let dir = import.meta.dirname;
|
||||
|
||||
while (dir !== path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "migrations");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
throw new Error(`Could not locate migrations folder relative to ${import.meta.dirname}`);
|
||||
}
|
||||
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
|
||||
export default definePlugin(async () => {
|
||||
console.info("Running database migrations...");
|
||||
|
||||
const connectionString = env.DATABASE_URL;
|
||||
|
||||
const pool = new Pool({ connectionString });
|
||||
const db = drizzle({ client: pool });
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { definePlugin } from "nitro";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
||||
|
||||
export default definePlugin(async () => {
|
||||
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
|
||||
|
||||
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dataDirectory, { recursive: true });
|
||||
await fs.access(dataDirectory, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(
|
||||
`Local storage path is not writable: ${dataDirectory}\n` +
|
||||
` ${message}\n` +
|
||||
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import { CreateApiKeyDialog } from "./create";
|
||||
|
||||
export const apiKeyDialogRendererRegistry = defineDialogRendererRegistry("api-key", [
|
||||
defineDialogRenderer("api-key.create", () => <CreateApiKeyDialog />),
|
||||
]);
|
||||
@@ -0,0 +1,3 @@
|
||||
import z from "zod";
|
||||
|
||||
export const apiKeyDialogSchemas = [z.object({ type: z.literal("api-key.create"), data: z.undefined() })] as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import { ChangePasswordDialog } from "./change-password";
|
||||
import { DisableTwoFactorDialog } from "./disable-two-factor";
|
||||
import { EnableTwoFactorDialog } from "./enable-two-factor";
|
||||
|
||||
export const authDialogRendererRegistry = defineDialogRendererRegistry("auth", [
|
||||
defineDialogRenderer("auth.change-password", () => <ChangePasswordDialog />),
|
||||
defineDialogRenderer("auth.two-factor.enable", () => <EnableTwoFactorDialog />),
|
||||
defineDialogRenderer("auth.two-factor.disable", () => <DisableTwoFactorDialog />),
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const authDialogSchemas = [
|
||||
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
|
||||
] as const;
|
||||
@@ -1,73 +1,11 @@
|
||||
import { match } from "ts-pattern";
|
||||
import { Dialog } from "@reactive-resume/ui/components/dialog";
|
||||
import { CreateApiKeyDialog } from "./api-key/create";
|
||||
import { ChangePasswordDialog } from "./auth/change-password";
|
||||
import { DisableTwoFactorDialog } from "./auth/disable-two-factor";
|
||||
import { EnableTwoFactorDialog } from "./auth/enable-two-factor";
|
||||
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from "./resume";
|
||||
import { ImportResumeDialog } from "./resume/import";
|
||||
import { CreateAwardDialog, UpdateAwardDialog } from "./resume/sections/award";
|
||||
import { CreateCertificationDialog, UpdateCertificationDialog } from "./resume/sections/certification";
|
||||
import { CreateCoverLetterDialog, UpdateCoverLetterDialog } from "./resume/sections/cover-letter";
|
||||
import { CreateCustomSectionDialog, UpdateCustomSectionDialog } from "./resume/sections/custom";
|
||||
import { CreateEducationDialog, UpdateEducationDialog } from "./resume/sections/education";
|
||||
import { CreateExperienceDialog, UpdateExperienceDialog } from "./resume/sections/experience";
|
||||
import { CreateInterestDialog, UpdateInterestDialog } from "./resume/sections/interest";
|
||||
import { CreateLanguageDialog, UpdateLanguageDialog } from "./resume/sections/language";
|
||||
import { CreateProfileDialog, UpdateProfileDialog } from "./resume/sections/profile";
|
||||
import { CreateProjectDialog, UpdateProjectDialog } from "./resume/sections/project";
|
||||
import { CreatePublicationDialog, UpdatePublicationDialog } from "./resume/sections/publication";
|
||||
import { CreateReferenceDialog, UpdateReferenceDialog } from "./resume/sections/reference";
|
||||
import { CreateSkillDialog, UpdateSkillDialog } from "./resume/sections/skill";
|
||||
import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./resume/sections/summary-item";
|
||||
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./resume/sections/volunteer";
|
||||
import { TemplateGalleryDialog } from "./resume/template/gallery";
|
||||
import { renderDialog } from "./renderers";
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
export function DialogManager() {
|
||||
const { open, activeDialog, onOpenChange } = useDialogStore();
|
||||
|
||||
const DialogContent = match(activeDialog)
|
||||
.with({ type: "auth.change-password" }, () => <ChangePasswordDialog />)
|
||||
.with({ type: "auth.two-factor.enable" }, () => <EnableTwoFactorDialog />)
|
||||
.with({ type: "auth.two-factor.disable" }, () => <DisableTwoFactorDialog />)
|
||||
.with({ type: "api-key.create" }, () => <CreateApiKeyDialog />)
|
||||
.with({ type: "resume.create" }, () => <CreateResumeDialog />)
|
||||
.with({ type: "resume.update" }, ({ data }) => <UpdateResumeDialog data={data} />)
|
||||
.with({ type: "resume.duplicate" }, ({ data }) => <DuplicateResumeDialog data={data} />)
|
||||
.with({ type: "resume.import" }, () => <ImportResumeDialog />)
|
||||
.with({ type: "resume.template.gallery" }, () => <TemplateGalleryDialog />)
|
||||
.with({ type: "resume.sections.profiles.create" }, ({ data }) => <CreateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.profiles.update" }, ({ data }) => <UpdateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.create" }, ({ data }) => <CreateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.update" }, ({ data }) => <UpdateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.create" }, ({ data }) => <CreateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.update" }, ({ data }) => <UpdateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.create" }, ({ data }) => <CreateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.update" }, ({ data }) => <UpdateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.create" }, ({ data }) => <CreateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.update" }, ({ data }) => <UpdateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.create" }, ({ data }) => <CreateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.update" }, ({ data }) => <UpdateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.create" }, ({ data }) => <CreateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.update" }, ({ data }) => <UpdateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.create" }, ({ data }) => <CreatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.update" }, ({ data }) => <UpdatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.create" }, ({ data }) => <CreateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.update" }, ({ data }) => <UpdateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.create" }, ({ data }) => <CreateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.update" }, ({ data }) => <UpdateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.create" }, ({ data }) => <CreateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.update" }, ({ data }) => <UpdateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.create" }, ({ data }) => <CreateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.update" }, ({ data }) => <UpdateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.create" }, ({ data }) => <CreateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.update" }, ({ data }) => <UpdateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.create" }, ({ data }) => <CreateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.update" }, ({ data }) => <UpdateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.create" }, ({ data }) => <CreateCustomSectionDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.update" }, ({ data }) => <UpdateCustomSectionDialog data={data} />)
|
||||
.otherwise(() => null);
|
||||
const DialogContent = renderDialog(activeDialog);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DialogSchema, DialogType } from "./schemas";
|
||||
|
||||
export interface DialogRendererEntry<T extends DialogType = DialogType> {
|
||||
type: T;
|
||||
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode;
|
||||
}
|
||||
|
||||
export type AnyDialogRendererEntry = {
|
||||
[T in DialogType]: DialogRendererEntry<T>;
|
||||
}[DialogType];
|
||||
|
||||
export interface DialogRendererRegistry {
|
||||
domain: string;
|
||||
renderers: readonly AnyDialogRendererEntry[];
|
||||
}
|
||||
|
||||
export const defineDialogRenderer = <T extends DialogType>(
|
||||
type: T,
|
||||
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode,
|
||||
): DialogRendererEntry<T> => ({ type, render });
|
||||
|
||||
export const defineDialogRendererRegistry = (
|
||||
domain: string,
|
||||
renderers: readonly AnyDialogRendererEntry[],
|
||||
): DialogRendererRegistry => ({ domain, renderers });
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DialogSchema } from "./schemas";
|
||||
import { apiKeyDialogRendererRegistry } from "./api-key/registry";
|
||||
import { authDialogRendererRegistry } from "./auth/registry";
|
||||
import { resumeDialogRendererRegistry } from "./resume/registry";
|
||||
|
||||
const dialogRendererRegistries = [
|
||||
authDialogRendererRegistry,
|
||||
apiKeyDialogRendererRegistry,
|
||||
resumeDialogRendererRegistry,
|
||||
] as const;
|
||||
|
||||
export const renderDialog = (dialog: DialogSchema | null) => {
|
||||
if (!dialog) return null;
|
||||
|
||||
for (const registry of dialogRendererRegistries) {
|
||||
const renderer = registry.renderers.find((entry) => entry.type === dialog.type);
|
||||
if (renderer) return renderer.render(dialog as never);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { usePatchResume } from "@/components/resume/builder-resume-draft";
|
||||
import { usePatchResume } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from ".";
|
||||
import { ImportResumeDialog } from "./import";
|
||||
import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award";
|
||||
import { CreateCertificationDialog, UpdateCertificationDialog } from "./sections/certification";
|
||||
import { CreateCoverLetterDialog, UpdateCoverLetterDialog } from "./sections/cover-letter";
|
||||
import { CreateCustomSectionDialog, UpdateCustomSectionDialog } from "./sections/custom";
|
||||
import { CreateEducationDialog, UpdateEducationDialog } from "./sections/education";
|
||||
import { CreateExperienceDialog, UpdateExperienceDialog } from "./sections/experience";
|
||||
import { CreateInterestDialog, UpdateInterestDialog } from "./sections/interest";
|
||||
import { CreateLanguageDialog, UpdateLanguageDialog } from "./sections/language";
|
||||
import { CreateProfileDialog, UpdateProfileDialog } from "./sections/profile";
|
||||
import { CreateProjectDialog, UpdateProjectDialog } from "./sections/project";
|
||||
import { CreatePublicationDialog, UpdatePublicationDialog } from "./sections/publication";
|
||||
import { CreateReferenceDialog, UpdateReferenceDialog } from "./sections/reference";
|
||||
import { CreateSkillDialog, UpdateSkillDialog } from "./sections/skill";
|
||||
import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./sections/summary-item";
|
||||
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./sections/volunteer";
|
||||
import { TemplateGalleryDialog } from "./template/gallery";
|
||||
|
||||
export const resumeDialogRendererRegistry = defineDialogRendererRegistry("resume", [
|
||||
defineDialogRenderer("resume.create", () => <CreateResumeDialog />),
|
||||
defineDialogRenderer("resume.update", ({ data }) => <UpdateResumeDialog data={data} />),
|
||||
defineDialogRenderer("resume.duplicate", ({ data }) => <DuplicateResumeDialog data={data} />),
|
||||
defineDialogRenderer("resume.import", () => <ImportResumeDialog />),
|
||||
defineDialogRenderer("resume.template.gallery", () => <TemplateGalleryDialog />),
|
||||
defineDialogRenderer("resume.sections.profiles.create", ({ data }) => <CreateProfileDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.profiles.update", ({ data }) => <UpdateProfileDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.experience.create", ({ data }) => <CreateExperienceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.experience.update", ({ data }) => <UpdateExperienceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.education.create", ({ data }) => <CreateEducationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.education.update", ({ data }) => <UpdateEducationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.skills.create", ({ data }) => <CreateSkillDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.skills.update", ({ data }) => <UpdateSkillDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.projects.create", ({ data }) => <CreateProjectDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.projects.update", ({ data }) => <UpdateProjectDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.certifications.create", ({ data }) => (
|
||||
<CreateCertificationDialog data={data} />
|
||||
)),
|
||||
defineDialogRenderer("resume.sections.certifications.update", ({ data }) => (
|
||||
<UpdateCertificationDialog data={data} />
|
||||
)),
|
||||
defineDialogRenderer("resume.sections.languages.create", ({ data }) => <CreateLanguageDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.languages.update", ({ data }) => <UpdateLanguageDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.publications.create", ({ data }) => <CreatePublicationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.publications.update", ({ data }) => <UpdatePublicationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.awards.create", ({ data }) => <CreateAwardDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.awards.update", ({ data }) => <UpdateAwardDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.interests.create", ({ data }) => <CreateInterestDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.interests.update", ({ data }) => <UpdateInterestDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.volunteer.create", ({ data }) => <CreateVolunteerDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.volunteer.update", ({ data }) => <UpdateVolunteerDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.references.create", ({ data }) => <CreateReferenceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.references.update", ({ data }) => <UpdateReferenceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.summary.create", ({ data }) => <CreateSummaryItemDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.summary.update", ({ data }) => <UpdateSummaryItemDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.cover-letter.create", ({ data }) => <CreateCoverLetterDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.cover-letter.update", ({ data }) => <UpdateCoverLetterDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.custom.create", ({ data }) => <CreateCustomSectionDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.custom.update", ({ data }) => <UpdateCustomSectionDialog data={data} />),
|
||||
]);
|
||||
@@ -0,0 +1,152 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
coverLetterItemSchema,
|
||||
customSectionSchema,
|
||||
educationItemSchema,
|
||||
experienceItemSchema,
|
||||
interestItemSchema,
|
||||
languageItemSchema,
|
||||
profileItemSchema,
|
||||
projectItemSchema,
|
||||
publicationItemSchema,
|
||||
referenceItemSchema,
|
||||
skillItemSchema,
|
||||
summaryItemSchema,
|
||||
volunteerItemSchema,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export const resumeDialogSchemas = [
|
||||
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.update"),
|
||||
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.duplicate"),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
shouldRedirect: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.create"),
|
||||
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.update"),
|
||||
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.create"),
|
||||
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.update"),
|
||||
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.create"),
|
||||
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.update"),
|
||||
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.create"),
|
||||
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.update"),
|
||||
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.create"),
|
||||
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.update"),
|
||||
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.create"),
|
||||
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.update"),
|
||||
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.create"),
|
||||
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.update"),
|
||||
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.create"),
|
||||
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.update"),
|
||||
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.create"),
|
||||
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.update"),
|
||||
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.create"),
|
||||
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.update"),
|
||||
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.create"),
|
||||
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.update"),
|
||||
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.create"),
|
||||
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.update"),
|
||||
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.create"),
|
||||
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.update"),
|
||||
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.create"),
|
||||
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.update"),
|
||||
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
|
||||
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
|
||||
] as const;
|
||||
@@ -17,8 +17,8 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -20,8 +20,8 @@ import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -19,8 +19,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -26,8 +26,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -21,8 +21,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDialogStore } from "@/dialogs/store";
|
||||
|
||||
const updateResumeData = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { metadata: { template: "ditto" } },
|
||||
}),
|
||||
|
||||
@@ -10,8 +10,8 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { templates } from "./data";
|
||||
|
||||
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import z from "zod";
|
||||
import { apiKeyDialogSchemas } from "./api-key/schema";
|
||||
import { authDialogSchemas } from "./auth/schema";
|
||||
import { resumeDialogSchemas } from "./resume/schema";
|
||||
|
||||
export const dialogSchemaRegistries = [
|
||||
{ domain: "auth", schemas: authDialogSchemas },
|
||||
{ domain: "api-key", schemas: apiKeyDialogSchemas },
|
||||
{ domain: "resume", schemas: resumeDialogSchemas },
|
||||
] as const;
|
||||
|
||||
const dialogSchemaEntries = [...authDialogSchemas, ...apiKeyDialogSchemas, ...resumeDialogSchemas] as const;
|
||||
|
||||
export const dialogTypeSchema = z.discriminatedUnion("type", dialogSchemaEntries);
|
||||
|
||||
export type DialogSchema = z.infer<typeof dialogTypeSchema>;
|
||||
export type DialogType = DialogSchema["type"];
|
||||
|
||||
export type DialogData<T extends DialogType> = Extract<DialogSchema, { type: T }>["data"];
|
||||
|
||||
type DialogPropsData<T extends DialogType> =
|
||||
DialogData<T> extends undefined ? Record<string, never> : { data: DialogData<T> };
|
||||
|
||||
export type DialogProps<T extends DialogType> = DialogPropsData<T>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dialogSchemaRegistries, dialogTypeSchema } from "./schemas";
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
describe("useDialogStore", () => {
|
||||
@@ -13,6 +14,13 @@ describe("useDialogStore", () => {
|
||||
});
|
||||
|
||||
describe("openDialog", () => {
|
||||
it("uses schema entries from domain registries", () => {
|
||||
expect(dialogSchemaRegistries.map((registry) => registry.domain)).toEqual(["auth", "api-key", "resume"]);
|
||||
expect(dialogTypeSchema.options).toHaveLength(
|
||||
dialogSchemaRegistries.reduce((total, registry) => total + registry.schemas.length, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a dialog and sets activeDialog", () => {
|
||||
useDialogStore.getState().openDialog("api-key.create", undefined);
|
||||
|
||||
|
||||
@@ -1,170 +1,7 @@
|
||||
import z from "zod";
|
||||
import type { DialogData, DialogProps, DialogSchema, DialogType } from "./schemas";
|
||||
import { create } from "zustand/react";
|
||||
import {
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
coverLetterItemSchema,
|
||||
customSectionSchema,
|
||||
educationItemSchema,
|
||||
experienceItemSchema,
|
||||
interestItemSchema,
|
||||
languageItemSchema,
|
||||
profileItemSchema,
|
||||
projectItemSchema,
|
||||
publicationItemSchema,
|
||||
referenceItemSchema,
|
||||
skillItemSchema,
|
||||
summaryItemSchema,
|
||||
volunteerItemSchema,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
|
||||
const dialogTypeSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("api-key.create"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.update"),
|
||||
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.duplicate"),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
shouldRedirect: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.create"),
|
||||
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.update"),
|
||||
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.create"),
|
||||
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.update"),
|
||||
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.create"),
|
||||
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.update"),
|
||||
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.create"),
|
||||
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.update"),
|
||||
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.create"),
|
||||
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.update"),
|
||||
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.create"),
|
||||
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.update"),
|
||||
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.create"),
|
||||
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.update"),
|
||||
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.create"),
|
||||
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.update"),
|
||||
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.create"),
|
||||
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.update"),
|
||||
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.create"),
|
||||
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.update"),
|
||||
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.create"),
|
||||
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.update"),
|
||||
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.create"),
|
||||
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.update"),
|
||||
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.create"),
|
||||
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.update"),
|
||||
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.create"),
|
||||
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.update"),
|
||||
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
|
||||
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
|
||||
]);
|
||||
|
||||
type DialogSchema = z.infer<typeof dialogTypeSchema>;
|
||||
type DialogType = DialogSchema["type"];
|
||||
|
||||
type DialogData<T extends DialogType> = Extract<DialogSchema, { type: T }>["data"];
|
||||
|
||||
type DialogPropsData<T extends DialogType> =
|
||||
DialogData<T> extends undefined ? Record<string, never> : { data: DialogData<T> };
|
||||
|
||||
export type DialogProps<T extends DialogType> = DialogPropsData<T>;
|
||||
export type { DialogProps };
|
||||
|
||||
interface DialogStoreState {
|
||||
open: boolean;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh w-dvw max-w-sm flex-col justify-center space-y-6 px-4 xs:px-0">
|
||||
<BrandIcon className="mb-4 size-20 self-center" />
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { email: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Sending password reset email...`);
|
||||
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: value.email,
|
||||
redirectTo: "/auth/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when requesting password reset email fails without backend message",
|
||||
message: "Failed to send password reset email. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
|
||||
if (submitted) return <PostForgotPasswordScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Remember your password?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from forgot-password page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on forgot-password form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on forgot-password form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on forgot-password form">Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostForgotPasswordScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to reset your password.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="mailto:">
|
||||
<Trans comment="Button label to open the user's default email app">Open Email Client</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "../components/social-auth";
|
||||
|
||||
const formSchema = z.object({
|
||||
identifier: z.string().trim().toLowerCase(),
|
||||
password: z.string().trim().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
disableEmailAuth: boolean;
|
||||
disableSignups: boolean;
|
||||
};
|
||||
|
||||
export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
try {
|
||||
const isEmail = value.identifier.includes("@");
|
||||
|
||||
const result = isEmail
|
||||
? await authClient.signIn.email({ email: value.identifier, password: value.password })
|
||||
: await authClient.signIn.username({ username: value.identifier, password: value.password });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when sign-in fails and no server error message is available",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresTwoFactor =
|
||||
result.data &&
|
||||
typeof result.data === "object" &&
|
||||
"twoFactorRedirect" in result.data &&
|
||||
result.data.twoFactorRedirect;
|
||||
|
||||
if (requiresTwoFactor) {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: "/auth/verify-2fa", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
} catch {
|
||||
toast.error(t`Failed to sign in. Please try again.`, { id: toastId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans comment="Title on the login page">Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
{!disableSignups && (
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/register">
|
||||
<Trans comment="Call-to-action link from login page to account registration page">
|
||||
Create one now
|
||||
</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="identifier">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for login identifier input that accepts email or username">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
autoComplete="section-login username webauthn"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder for login identifier field",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
<FormDescription>
|
||||
<Trans>You can also use your username to login.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on login form">Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto p-0 text-xs leading-none"
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans comment="Link label to password reset page from login form">Forgot Password?</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-login current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides the password in login form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals the password in login form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on login form">Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "../components/social-auth";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3).max(64),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9._-]+$/, {
|
||||
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
|
||||
}),
|
||||
email: z.email().toLowerCase(),
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
disableEmailAuth: boolean;
|
||||
};
|
||||
|
||||
export function RegisterPage({ disableEmailAuth }: Props) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { name: "", username: "", email: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing up...`);
|
||||
|
||||
const { error } = await authClient.signUp.email({
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
username: value.username,
|
||||
displayUsername: value.username,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when account registration fails without a server error message",
|
||||
message: "Failed to create your account. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
|
||||
if (submitted) return <PostSignupScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for full name input on registration form">Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on registration form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="username">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for username input on registration form">Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register username"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on registration form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on registration form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="section-register email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on registration form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on registration form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-register new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in registration form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in registration form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on registration form">Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth requestSignUp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSignupScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to verify your account.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
<Trans>This step is optional, but recommended.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Verifying your email is required when resetting your password.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function ResetPasswordPage({ token }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Resetting your password...`);
|
||||
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: value.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when resetting password fails and no backend message is available",
|
||||
message: "Failed to reset your password. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been reset successfully. You can now sign in with your new password.`, {
|
||||
id: toastId,
|
||||
});
|
||||
|
||||
void navigate({ to: "/auth/login" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Reset your password</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Please enter a new password for your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in reset-password form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in reset-password form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on reset-password form">Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function ResumePasswordPage({ redirectPath }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
|
||||
|
||||
const [username, slug] = useMemo(() => {
|
||||
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
|
||||
if (!username || !slug) throw navigate({ to: "/" });
|
||||
return [username, slug];
|
||||
}, [redirectPath, navigate]);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
const toastId = toast.loading(t`Verifying password...`);
|
||||
|
||||
verifyPassword(
|
||||
{ username, slug, password: value.password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: redirectPath, replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "INVALID_PASSWORD") {
|
||||
toast.dismiss(toastId);
|
||||
formApi.setFieldMeta("password", (meta) => ({
|
||||
...meta,
|
||||
isTouched: true,
|
||||
errors: [{ message: t`The password you entered is incorrect` }],
|
||||
errorMap: {
|
||||
...meta.errorMap,
|
||||
onSubmit: { message: t`The password you entered is incorrect` },
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resume password verification fails unexpectedly",
|
||||
message: "Failed to verify the password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>The resume you are trying to access is password protected</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground leading-relaxed">
|
||||
<Trans>Please enter the password shared with you by the owner of the resume to continue.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on protected resume access form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password on protected resume screen",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password on protected resume screen",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans comment="Primary action button label to unlock a password-protected resume">Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().trim(),
|
||||
});
|
||||
|
||||
export function VerifyTwoFactorBackupPage() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying backup code...`);
|
||||
const formattedCode = `${value.code.slice(0, 5)}-${value.code.slice(5)}`;
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
|
||||
message: "Failed to verify your backup code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Verify with a Backup Code</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter one of your saved backup codes to access your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
maxLength={10}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit backup code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
});
|
||||
|
||||
export function VerifyTwoFactorPage() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code: value.code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a two-factor authentication code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter the verification code from your authenticator app</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="number"
|
||||
maxLength={6}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto justify-self-center p-0 text-sm"
|
||||
render={
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
|
||||
Lost access to your authenticator?
|
||||
</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user