diff --git a/.env.example b/.env.example index 750be2c4..931436a3 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,14 @@ SUPER_ADMIN_NAME=Administrator # Cron CRON_SECRET= + +# Axiom — Log Storage (https://axiom.co/docs/guides/pino) +AXIOM_TOKEN=AXIOM_TOKEN +AXIOM_DATASET=DATA_SET + +# Cloudflare R2 — Object Storage +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME= +R2_PUBLIC_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffc36ae8..c0315684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,28 @@ env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - + GDRIVE_SERVICE_ACCOUNT_KEY: ${{secrets.GDRIVE_SERVICE_ACCOUNT_KEY}} + R2_ACCOUNT_ID: ${{secrets.R2_ACCOUNT_ID}} + R2_ACCESS_KEY_ID: ${{secrets.R2_ACCESS_KEY_ID}} + R2_SECRET_ACCESS_KEY: ${{secrets.R2_SECRET_ACCESS_KEY}} + R2_BUCKET_NAME: ${{secrets.R2_BUCKET_NAME}} + R2_PUBLIC_URL: ${{secrets.R2_PUBLIC_URL}} + AXIOM_TOKEN: ${{secrets.AXIOM_TOKEN}} + AXIOM_DATASET: ${{secrets.AXIOM_DATASET}} + CRON_SECRET: ${{secrets.CRON_SECRET}} + BETTER_AUTH_SECRET: ${{secrets.BETTER_AUTH_SECRET}} + BETTER_AUTH_URL: http://localhost:3000 + BETTER_AUTH_COOKIE_PREFIX: domus_auth_test + GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} + GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} + SUPER_ADMIN_EMAIL: superadmin@test.com + SUPER_ADMIN_PASSWORD: testing123 + SUPER_ADMIN_NAME: Super Admin + NEXT_PUBLIC_DOMUS_VERSION: 0.0.0-dev + CF_API_TOKEN: ${{secrets.CF_API_TOKEN}} + CF_ACCOUNT_ID: ${{secrets.CF_ACCOUNT_ID}} + WHATSAPP_TOKEN: ${{secrets.WHATSAPP_TOKEN}} + jobs: check: name: Lint & Type Check diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a8aebafe..0ec5e82a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,8 +16,8 @@ env: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: - deploy: - name: Vercel Deploy + dash: + name: Dashboard runs-on: ubuntu-latest env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} @@ -55,4 +55,4 @@ jobs: NEXT_PUBLIC_DOMUS_VERSION: ${{ github.ref_name }} - name: Deploy Project Artifacts to Vercel - run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} \ No newline at end of file + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 071880d7..3ab70306 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,4 +307,4 @@ See → [docs/tdd.md — Deployment & Infrastructure](docs/tdd.md#9-deployment-- ## License -MIT — see → [docs/tdd.md — License](docs/tdd.md#11-license) +MIT — see → [docs/tdd.md — License](docs/tdd.md#12-license) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 861637fb..d2c7d644 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ pnpm dev ``` domus/ ├── apps/ -│ ├── dash/ # Next.js 15 — main application (dash.pkrbt.id) +│ ├── dash/ # Next.js 15 — main application (pkrbt.id) │ └── cron/ # Cloudflare Workers — scheduled tasks ├── packages/ │ ├── core/ # Framework-agnostic business logic (Clean Architecture) diff --git a/apps/cron/.gitignore b/apps/cron/.gitignore new file mode 100644 index 00000000..7310e736 --- /dev/null +++ b/apps/cron/.gitignore @@ -0,0 +1 @@ +.wrangler \ No newline at end of file diff --git a/apps/cron/package.json b/apps/cron/package.json index edfe127a..de9a0ce6 100644 --- a/apps/cron/package.json +++ b/apps/cron/package.json @@ -2,9 +2,11 @@ "name": "@domus/cron", "version": "0.0.1", "private": true, + "type": "module", "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", + "build": "tsdown", + "dev": "tsdown --watch", + "deploy": "pnpm run build && wrangler deploy dist/index.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -12,7 +14,8 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250224.0", + "tsdown": "^0.21.7", "typescript": "^5.7.3", - "wrangler": "^3.111.0" + "wrangler": "^4.79.0" } } diff --git a/apps/cron/tsdown.config.ts b/apps/cron/tsdown.config.ts new file mode 100644 index 00000000..e1c1a278 --- /dev/null +++ b/apps/cron/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + // Equivalent to previous bundle: true + unbundle: false, + deps: { + // Equivalent to previous external: [/^cloudflare:/] + neverBundle: [/^cloudflare:/], + }, + minify: true, +}); diff --git a/apps/cron/wrangler.toml b/apps/cron/wrangler.toml index cfae0a50..d584ff1e 100644 --- a/apps/cron/wrangler.toml +++ b/apps/cron/wrangler.toml @@ -1,11 +1,11 @@ name = "domus-cron" -main = "src/index.ts" +main = "dist/index.mjs" compatibility_date = "2026-04-01" [triggers] -crons = ["0 0 * * 0"] +crons = ["0 0 * * 7"] [vars] -DASH_API_URL = "https://dash.pkrbt.id" +DASH_API_URL = "https://pkrbt.id" # CRON_SECRET must be set via: wrangler secret put CRON_SECRET diff --git a/apps/dash/app/api/cron/rotate-join-id/route.ts b/apps/dash/app/api/cron/rotate-join-id/route.ts index e4a11c66..c32b4d0f 100644 --- a/apps/dash/app/api/cron/rotate-join-id/route.ts +++ b/apps/dash/app/api/cron/rotate-join-id/route.ts @@ -1,2 +1,2 @@ // Thin export only — no logic here. -export { POST } from "@/app/api-routes/cron/rotate-join-id"; +export { POST } from "@/src/api-routes/cron/rotate-join-id"; diff --git a/apps/dash/app/api/upload/route.ts b/apps/dash/app/api/upload/route.ts new file mode 100644 index 00000000..60018556 --- /dev/null +++ b/apps/dash/app/api/upload/route.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { logger } from "@/shared/core/logger"; + +/** + * Mock upload API for E2E testing and development. + * This should be replaced with a real storage implementation (e.g. R2, GDrive) in production. + */ +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + const type = formData.get("type") as string; + const id = formData.get("id") as string; + + logger.info("Mock Upload API received file", { + name: file?.name, + size: file?.size, + type, + id, + }); + + // Dummy delay to simulate network latency for the test progress bar + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Return a dummy URL (pointing back to a placeholder or something valid) + // In a real implementation, this would be the URL from the storage provider. + const dummyUrl = `https://images.unsplash.com/photo-1518173946687-a4c8a9833d8e?q=80&w=1000&auto=format&fit=crop`; + + return NextResponse.json({ + success: true, + url: dummyUrl, + }); + } catch (error) { + logger.error("Mock Upload API error", { error }); + return NextResponse.json( + { success: false, message: "Upload failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/dash/app/globals.css b/apps/dash/app/globals.css index 40a18533..3ef0492c 100644 --- a/apps/dash/app/globals.css +++ b/apps/dash/app/globals.css @@ -206,6 +206,17 @@ --font-heading: "Plus Jakarta Sans", sans-serif; --font-body: "Inter", sans-serif; --font-sans: "Inter", sans-serif; + + /* Animations */ + @keyframes shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } + } + --animate-shimmer: shimmer 2s infinite linear; } /* ------------------------------------------------------------- diff --git a/apps/dash/e2e/features/org/join.spec.ts b/apps/dash/e2e/features/org/join.spec.ts index b9f9f65c..63a864d9 100644 --- a/apps/dash/e2e/features/org/join.spec.ts +++ b/apps/dash/e2e/features/org/join.spec.ts @@ -51,7 +51,9 @@ test.describe("Organization Join Page", () => { await expect(page).toHaveURL(new RegExp(`/join/${TEST_JOIN_ID}/success`), { timeout: 10000, }); - await expect(page.getByText(/Pendaftaran Berhasil!/i)).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Pendaftaran Berhasil!/i }), + ).toBeVisible(); }); test("should show already registered message when joining twice", async ({ @@ -76,7 +78,12 @@ test.describe("Organization Join Page", () => { await page.getByRole("button", { name: /Bergabung Sekarang/i }).click(); // Verify first one is "Success" - await expect(page.getByText(/Pendaftaran Berhasil!/i)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/join/${TEST_JOIN_ID}/success`), { + timeout: 20000, + }); + await expect( + page.getByRole("heading", { name: /Pendaftaran Berhasil!/i }), + ).toBeVisible({ timeout: 10000 }); // 3. Perform second registration (same user, same org) await page.goto(`/join/${TEST_JOIN_ID}`); @@ -91,7 +98,9 @@ test.describe("Organization Join Page", () => { new RegExp(`/join/${TEST_JOIN_ID}/success\\?exists=true`), { timeout: 10000 }, ); - await expect(page.getByText(/Pendaftaran Sudah Ada/i)).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Pendaftaran Sudah Ada/i }), + ).toBeVisible({ timeout: 15000 }); await expect(page.getByText(/Anda sudah terdaftar/i)).toBeVisible(); }); @@ -102,6 +111,8 @@ test.describe("Organization Join Page", () => { accountStatus: AccountStatus.Pending, }); await page.goto("/join/invalid-id-999"); - await expect(page.getByText(/Link Tidak Valid/i)).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Link Tidak Valid/i }), + ).toBeVisible(); }); }); diff --git a/apps/dash/e2e/features/org/manage.spec.ts b/apps/dash/e2e/features/org/manage.spec.ts index 0041dafd..d0369ee3 100644 --- a/apps/dash/e2e/features/org/manage.spec.ts +++ b/apps/dash/e2e/features/org/manage.spec.ts @@ -51,8 +51,10 @@ test.describe("Organization Update & Remove Page", () => { test("should soft remove an existing organization", async ({ page }) => { // 1. Create a dummy organization to test removal + const randomSuffix = Math.random().toString(36).substring(7); + const orgName = `Organisasi Untuk Dihapus ${randomSuffix}`; await page.goto("/org/new"); - await page.locator("#name").fill("Organisasi Untuk Dihapus"); + await page.locator("#name").fill(orgName); await page.getByRole("button", { name: /Simpan/i }).click(); // 2. Wait for redirect to details page @@ -74,7 +76,87 @@ test.describe("Organization Update & Remove Page", () => { page .getByTestId("org-grid") .locator("> div") - .filter({ hasText: "Organisasi Untuk Dihapus" }), + .filter({ hasText: orgName }), ).toHaveCount(0); }); + + test("should inline edit organization name, description, cover, and logo", async ({ + page, + }) => { + // 1. Navigate to /org and click on the first organization to edit + await page.goto("/org"); + const firstCardLink = page.getByTestId("org-grid").locator("a").first(); + const orgUrlPath = await firstCardLink.getAttribute("href"); + if (!orgUrlPath) throw new Error("No URL found"); + + await page.goto(orgUrlPath); + + // 2. Edit Name + const nameGroup = page.locator(".group\\/name"); + await nameGroup.hover(); + await page.getByRole("button", { name: "Ubah Nama" }).click(); + + const nameInput = page.locator("input.font-heading"); + await expect(nameInput).toBeVisible(); + await nameInput.fill("Nama Inline Baru"); + await page.keyboard.press("Enter"); + + await expect( + page.getByRole("heading", { name: "Nama Inline Baru" }), + ).toBeVisible(); + + // 3. Edit Description + const descGroup = page.locator(".group\\/desc"); + await descGroup.hover(); + await page.getByRole("button", { name: "Ubah Deskripsi" }).click(); + + const descTextarea = page.locator("textarea.font-body"); + await expect(descTextarea).toBeVisible(); + await descTextarea.fill("Deskripsi Inline Baru"); + // Click the check button + await page.locator("textarea.font-body + div > button:first-child").click(); + + await expect(page.getByText("Deskripsi Inline Baru")).toBeVisible(); + + // 4. Edit Cover + const headerGroup = page.locator(".group\\/header"); + await headerGroup.hover(); + const ubahCoverBtn = page.getByRole("button", { name: "Ubah Cover" }); + await expect(ubahCoverBtn).toBeVisible(); + + // In Playwright, we can handle the file chooser before clicking + const fileChooserPromise = page.waitForEvent("filechooser"); + await ubahCoverBtn.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "cover.jpg", + mimeType: "image/jpeg", + buffer: Buffer.from("fake image content"), + }); + + // We expect the progress overlay to appear and then disappear (upload completes) + await expect(page.getByText(/Uploading/i)).toBeVisible(); + await expect(page.getByText(/Uploading/i)).toBeHidden(); + + // 5. Edit Logo + const logoGroup = page + .locator("div.w-24.h-24.group, div.w-28.h-28.group") + .first(); + await logoGroup.hover(); + const ubahLogoBtn = page.getByRole("button", { name: "Ubah Logo" }); + await expect(ubahLogoBtn).toBeVisible(); + + const logoChooserPromise = page.waitForEvent("filechooser"); + await ubahLogoBtn.click(); + const logoChooser = await logoChooserPromise; + await logoChooser.setFiles({ + name: "logo.jpg", + mimeType: "image/jpeg", + buffer: Buffer.from("fake image content"), + }); + + // Wait for upload progress to finish + await expect(page.getByText(/Uploading/i)).toBeVisible(); + await expect(page.getByText(/Uploading/i)).toBeHidden(); + }); }); diff --git a/apps/dash/next.config.ts b/apps/dash/next.config.ts index 84963ca8..05f4de90 100644 --- a/apps/dash/next.config.ts +++ b/apps/dash/next.config.ts @@ -19,8 +19,18 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "placehold.co", }, + { + protocol: "https", + hostname: "**.pkrbt.id", + }, ], }, + serverExternalPackages: [ + "@axiomhq/pino", + "pino", + "thread-stream", + "pino-pretty", + ], }; export default withNextIntl(nextConfig); diff --git a/apps/dash/package.json b/apps/dash/package.json index c743442e..822fbd80 100644 --- a/apps/dash/package.json +++ b/apps/dash/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "biome check", "format": "biome format --write", @@ -16,14 +16,17 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "auth:gen": "npx auth@latest generate --config src/shared/auth/server.ts --output=../../packages/db/src/schema/better-auth.ts", + "auth:patch": "tsx ./seed/patch-schema.ts", "db:seed": "tsx ./seed/index.ts" }, "dependencies": { + "@axiomhq/pino": "^1.6.0", "@base-ui/react": "^1.3.0", "@domus/auth": "workspace:*", "@domus/config": "workspace:*", "@domus/core": "workspace:*", "@domus/db": "workspace:*", + "@domus/storage": "workspace:*", "@fontsource/inter": "^5.2.8", "@fontsource/plus-jakarta-sans": "^5.2.8", "@hello-pangea/dnd": "^18.0.1", @@ -31,6 +34,7 @@ "@tanstack/react-form": "^1.28.6", "@types/uuid": "^11.0.0", "better-auth": "1.5.6", + "browser-image-compression": "^2.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -40,6 +44,7 @@ "next": "16.2.1", "next-intl": "^4.8.3", "next-themes": "^0.4.6", + "pino": "^10.3.1", "react": "19.2.4", "react-day-picker": "^9.14.0", "react-dom": "19.2.4", @@ -56,9 +61,11 @@ "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@types/node": "^24.0.0", + "@types/pino": "^7.0.5", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-istanbul": "^4.1.0", + "pino-pretty": "^13.1.3", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.0", diff --git a/apps/dash/proxy.ts b/apps/dash/proxy.ts index 9cdd95e7..709f3c3f 100644 --- a/apps/dash/proxy.ts +++ b/apps/dash/proxy.ts @@ -2,6 +2,7 @@ import c from "@domus/config"; import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; import auth from "@/shared/auth/server"; +import { logger } from "@/shared/core/logger"; import { routing } from "@/shared/i18n/routing"; /** @@ -12,70 +13,94 @@ import { routing } from "@/shared/i18n/routing"; */ export default async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - - // 1. Get session - const session: typeof auth.$Infer.Session | null = await auth.api.getSession({ - headers: await headers(), + logger.info("proxy: start", { + method: request.method, + pathname, }); - // 2. Auth Redirection Logic - const isPublicPage = pathname.startsWith("/login"); - const isStatusPage = - pathname.startsWith("/pending") || pathname.startsWith("/rejected"); + try { + // 1. Get session + const session: typeof auth.$Infer.Session | null = + await auth.api.getSession({ + headers: await headers(), + }); - if (!session) { - // Unauthenticated user - if (!isPublicPage && !isStatusPage) { - const url = new URL("/login", request.url); - url.searchParams.set("callbackUrl", pathname); - return NextResponse.redirect(url); - } - } else { - if (!pathname.startsWith("/join")) { - // Authenticated user - const status = session.context.accountStatus; + // 2. Auth Redirection Logic + const isPublicPage = pathname.startsWith("/login"); + const isStatusPage = + pathname.startsWith("/pending") || pathname.startsWith("/rejected"); - if (status === "pending" && pathname !== "/pending") { - return NextResponse.redirect(new URL("/pending", request.url)); + if (!session) { + // Unauthenticated user + if (!isPublicPage && !isStatusPage) { + const url = new URL("/login", request.url); + url.searchParams.set("callbackUrl", pathname); + logger.warn("proxy: unauthenticated_redirect", { pathname }); + return NextResponse.redirect(url); } + } else { + logger.info("proxy: session_found", { + userId: session.user.id, + accountStatus: session.context.accountStatus, + }); - if (status === "rejected" && pathname !== "/rejected") { - return NextResponse.redirect(new URL("/rejected", request.url)); - } + if (!pathname.startsWith("/join")) { + // Authenticated user + const status = session.context.accountStatus; - if (status === "approved" && (isPublicPage || isStatusPage)) { - return NextResponse.redirect(new URL("/", request.url)); + if (status === "pending" && pathname !== "/pending") { + logger.warn("proxy: status_redirect", { status, pathname }); + return NextResponse.redirect(new URL("/pending", request.url)); + } + + if (status === "rejected" && pathname !== "/rejected") { + logger.warn("proxy: status_redirect", { status, pathname }); + return NextResponse.redirect(new URL("/rejected", request.url)); + } + + if (status === "approved" && (isPublicPage || isStatusPage)) { + logger.info("proxy: status_redirect", { status, pathname }); + return NextResponse.redirect(new URL("/", request.url)); + } } } - } - // 3. i18n Locale Handling (from original proxy.ts) foobar - const cookieName = c.app.locale.cookieName; - const cookieLocale = request.cookies.get(cookieName)?.value; + // 3. i18n Locale Handling (from original proxy.ts) foobar + const cookieName = c.app.locale.cookieName; + const cookieLocale = request.cookies.get(cookieName)?.value; - // Use cookie if available, otherwise default - const finalLocale = cookieLocale || routing.defaultLocale; + // Use cookie if available, otherwise default + const finalLocale = cookieLocale || routing.defaultLocale; - // 4. Create the response and set local headers - const requestHeaders = new Headers(request.headers); - requestHeaders.set("X-NEXT-INTL-LOCALE", finalLocale); + // 4. Create the response and set local headers + const requestHeaders = new Headers(request.headers); + requestHeaders.set("X-NEXT-INTL-LOCALE", finalLocale); - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - - // 5. Persist the locale in our custom cookie if it's missing or changed - if (cookieLocale !== finalLocale) { - response.cookies.set(cookieName, finalLocale, { - path: "/", - maxAge: 60 * 60 * 24 * 365, // 1 year - sameSite: "lax", + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, }); - } - return response; + // 5. Persist the locale in our custom cookie if it's missing or changed + if (cookieLocale !== finalLocale) { + logger.info("proxy: locale_persistence", { + old: cookieLocale, + final: finalLocale, + }); + response.cookies.set(cookieName, finalLocale, { + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: "lax", + }); + } + + return response; + } catch (error) { + const message = error instanceof Error ? error.message : "Internal Error"; + logger.error("proxy: error", { message, pathname }); + throw error; + } } export const config = { diff --git a/apps/dash/seed/index.ts b/apps/dash/seed/index.ts index 45ebb605..950ee175 100644 --- a/apps/dash/seed/index.ts +++ b/apps/dash/seed/index.ts @@ -1,5 +1,5 @@ import c from "@domus/config"; -import { UserRole } from "@domus/core"; +import { AccountStatus, UserRole } from "@domus/core"; import { ensureUser } from "./helper/user"; import { seedOrganization } from "./org"; @@ -12,6 +12,7 @@ async function initSuperAdmin(_isDryRun: boolean) { email: c.initial.adminEmail, password: c.initial.adminPassword, role: [UserRole.SuperAdmin], + accountStatus: AccountStatus.Approved, }); } diff --git a/apps/dash/seed/patch-schema.ts b/apps/dash/seed/patch-schema.ts new file mode 100644 index 00000000..f9114210 --- /dev/null +++ b/apps/dash/seed/patch-schema.ts @@ -0,0 +1,136 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Patch script to convert all schema IDs in packages/db/src/schema from text to uuid. + * + * Usage: tsx apps/dash/seed/patch-schema.ts + */ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SCHEMA_DIR = path.resolve(__dirname, "../../../packages/db/src/schema"); + +function patchFile(filePath: string) { + let content = fs.readFileSync(filePath, "utf8"); + let modified = false; + + // 1. Patch primary keys and remove $defaultFn + // Pattern 1: uuid('id').primaryKey().$defaultFn(() => uuidv7()) + const pkUuidDefaultRegex = + /id:\s*uuid\(['"]id['"]\)\s*\.primaryKey\(\)\s*\.\$defaultFn\([^)]*\)/g; + if (pkUuidDefaultRegex.test(content)) { + content = content.replace( + pkUuidDefaultRegex, + 'id: uuid("id").primaryKey()', + ); + modified = true; + } + + // Pattern 2: text('id').primaryKey() + const pkTextRegex = /id:\s*text\(['"]id['"]\)\.primaryKey\(\)/g; + if (pkTextRegex.test(content)) { + content = content.replace(pkTextRegex, 'id: uuid("id").primaryKey()'); + modified = true; + } + + // 2. Patch foreign keys and related columns + const fkPatterns = [ + { + regex: /userId:\s*text\(['"]user_id['"]\)/g, + replacement: 'userId: uuid("user_id")', + }, + { + regex: /organizationId:\s*text\(['"]organization_id['"]\)/g, + replacement: 'organizationId: uuid("organization_id")', + }, + { + regex: /inviterId:\s*text\(['"]inviter_id['"]\)/g, + replacement: 'inviterId: uuid("inviter_id")', + }, + { + regex: /parentId:\s*text\(['"]parent_id['"]\)/g, + replacement: 'parentId: uuid("parent_id")', + }, + { + regex: /impersonatedBy:\s*text\(['"]impersonated_by['"]\)/g, + replacement: 'impersonatedBy: uuid("impersonated_by")', + }, + { + regex: /activeOrganizationId:\s*text\(['"]active_organization_id['"]\)/g, + replacement: 'activeOrganizationId: uuid("active_organization_id")', + }, + { + regex: /createdBy:\s*text\(['"]created_by['"]\)/g, + replacement: 'createdBy: uuid("created_by")', + }, + { + regex: /updatedBy:\s*text\(['"]updated_by['"]\)/g, + replacement: 'updatedBy: uuid("updated_by")', + }, + { + regex: /verifiedBy:\s*text\(['"]verified_by['"]\)/g, + replacement: 'verifiedBy: uuid("verified_by")', + }, + { + regex: /lockedBy:\s*text\(['"]locked_by['"]\)/g, + replacement: 'lockedBy: uuid("locked_by")', + }, + ]; + + for (const pattern of fkPatterns) { + if (pattern.regex.test(content)) { + content = content.replace(pattern.regex, pattern.replacement); + modified = true; + } + } + + if (modified) { + // 3. Update imports + // Ensure uuid is imported from drizzle-orm/pg-core + const pgCoreImportRegex = + /import\s*{\s*([^}]*)\s*}\s*from\s*['"]drizzle-orm\/pg-core['"];/; + const match = content.match(pgCoreImportRegex); + + if (match) { + const imports = match[1]; + const importList = imports + .split(",") + .map((i) => i.trim()) + .filter(Boolean); + + if (!importList.includes("uuid")) { + importList.push("uuid"); + importList.sort(); + const newImports = `import {\n ${importList.join(",\n ")},\n} from "drizzle-orm/pg-core";`; + content = content.replace(pgCoreImportRegex, newImports); + } + } + + // 4. Remove unused uuid package imports + content = content.replace( + /import\s*{\s*v7\s*as\s*uuidv7\s*}\s*from\s*['"]uuid['"];?\n?/g, + "", + ); + content = content.replace( + /import\s*{\s*v7\s*}\s*from\s*['"]uuid['"];?\n?/g, + "", + ); + + fs.writeFileSync(filePath, content, "utf8"); + console.log(`Patched: ${filePath}`); + } +} + +function run() { + const betterAuthSchema = path.join(SCHEMA_DIR, "better-auth.ts"); + if (fs.existsSync(betterAuthSchema)) { + patchFile(betterAuthSchema); + } else { + console.error(`Could not find better-auth schema at: ${betterAuthSchema}`); + } +} + +run(); diff --git a/apps/dash/src/app/api-routes/cron/rotate-join-id.ts b/apps/dash/src/api-routes/cron/rotate-join-id.ts similarity index 100% rename from apps/dash/src/app/api-routes/cron/rotate-join-id.ts rename to apps/dash/src/api-routes/cron/rotate-join-id.ts diff --git a/apps/dash/src/pages/auth/ui/LoginPage.tsx b/apps/dash/src/pages/auth/ui/LoginPage.tsx index 7210bd5f..8bece980 100644 --- a/apps/dash/src/pages/auth/ui/LoginPage.tsx +++ b/apps/dash/src/pages/auth/ui/LoginPage.tsx @@ -2,6 +2,7 @@ import c, { Environment } from "@domus/config"; import type { Metadata } from "next"; import { useTranslations } from "next-intl"; import { Card, CardContent } from "@/shared/ui/shadcn/card"; +import { Brand } from "@/widgets/layout/ui/components/Brand"; import { LoginForm } from "./components/LoginForm"; export const LoginMetadata: Metadata = { @@ -11,7 +12,7 @@ export const LoginMetadata: Metadata = { /** * Login page UI component. * - * Renders the "Sacred Dusk" branded login page template. + * Renders a warm, casual login page with glassmorphism effects. * Delegating authentication form logic to the LoginForm feature. * * @returns The LoginPage component. @@ -21,29 +22,33 @@ export function LoginPage() { const emailPasswordForm = c.app.env !== Environment.Production; return ( - <> - {/* LoginPage Start */} -
- {t("subheading")} -
-+ {t("subheading")} +