Compare commits
No commits in common. "3b807aa74dfe6f497b645846fa80bfd9c3cc5c92" and "6f863ba659f7b4014856fb72e46121bdf2c9a0af" have entirely different histories.
3b807aa74d
...
6f863ba659
@ -1,91 +0,0 @@
|
|||||||
# Auth and Settings Migration Notes (Feb 21, 2026)
|
|
||||||
|
|
||||||
This document captures the auth/settings stabilization work completed during the SQLite -> Supabase migration cleanup.
|
|
||||||
|
|
||||||
## Why this was needed
|
|
||||||
|
|
||||||
The app had overlapping auth models:
|
|
||||||
|
|
||||||
- Supabase Auth tables (`auth.users`)
|
|
||||||
- App tables (`public.users`, `public.sessions`, `public.password_reset_tokens`)
|
|
||||||
|
|
||||||
The app was still expecting `public.users.password_hash`, but that column was not present in the migrated schema, which caused:
|
|
||||||
|
|
||||||
- login failures
|
|
||||||
- reset password failures (`Could not find the 'password_hash' column...`)
|
|
||||||
- settings profile/session issues
|
|
||||||
|
|
||||||
## Environment key mapping that must be correct
|
|
||||||
|
|
||||||
Use different values for these:
|
|
||||||
|
|
||||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` -> anon/publishable key
|
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY` -> service_role/secret key
|
|
||||||
|
|
||||||
If these are the same value, auth/session/profile flows break.
|
|
||||||
|
|
||||||
## Database work completed (Phase 1)
|
|
||||||
|
|
||||||
Phase 1 schema alignment was completed in Supabase:
|
|
||||||
|
|
||||||
- created `public.profiles` with `id` referencing `auth.users(id)`
|
|
||||||
- enabled RLS policies for own profile access
|
|
||||||
- backfilled profile rows from existing data
|
|
||||||
- optional trigger created to auto-create profile rows for new auth users
|
|
||||||
|
|
||||||
`public.users` was retained for compatibility during transition.
|
|
||||||
|
|
||||||
## Code changes made
|
|
||||||
|
|
||||||
### 1) `src/lib/server/auth.ts`
|
|
||||||
|
|
||||||
- `registerUser` now creates credentials in Supabase Auth (`auth.users`) via admin API.
|
|
||||||
- `authenticateUser` now verifies credentials using `signInWithPassword` instead of `public.users.password_hash`.
|
|
||||||
- `updateUserAccount` now updates password/email in Supabase Auth admin API.
|
|
||||||
- Profile fields are mirrored to:
|
|
||||||
- `public.users`
|
|
||||||
- `public.profiles`
|
|
||||||
- Existing custom `gantt_session` flow remains in place for compatibility.
|
|
||||||
|
|
||||||
### 2) `src/app/api/auth/reset-password/route.ts`
|
|
||||||
|
|
||||||
- Removed dependency on `public.users.password_hash`.
|
|
||||||
- Password reset now updates Supabase Auth user password through admin API.
|
|
||||||
- Added fallback for legacy records: if auth user is missing, create it with the same UUID, then set password.
|
|
||||||
|
|
||||||
### 3) `src/app/settings/page.tsx`
|
|
||||||
|
|
||||||
- Hardened response parsing for session/profile/password requests to avoid client runtime errors on malformed responses.
|
|
||||||
- Fixed avatar preset rendering key collisions:
|
|
||||||
- deduped generated preset URLs
|
|
||||||
- switched to stable numeric keys (not data URL strings)
|
|
||||||
|
|
||||||
## Current state (important)
|
|
||||||
|
|
||||||
Auth is currently hybrid but stable:
|
|
||||||
|
|
||||||
- credentials/passwords: Supabase Auth (`auth.users`)
|
|
||||||
- app profile fields: `public.profiles` (and mirrored `public.users`)
|
|
||||||
- app session guard cookie: custom `gantt_session` is still used by this codebase
|
|
||||||
|
|
||||||
This is intentional as an intermediate compatibility stage.
|
|
||||||
|
|
||||||
## Known behavior
|
|
||||||
|
|
||||||
Forgot password route currently generates reset links for dev/testing flow and does not send production email by itself unless an email provider flow is added.
|
|
||||||
|
|
||||||
## Validation checklist used
|
|
||||||
|
|
||||||
- login works after env fix
|
|
||||||
- reset password no longer fails on missing `password_hash`
|
|
||||||
- settings screen loads name/email/avatar
|
|
||||||
- avatar save no longer returns auth/session-related errors
|
|
||||||
- duplicate React key warning in avatar presets resolved
|
|
||||||
|
|
||||||
## Next cleanup (recommended)
|
|
||||||
|
|
||||||
To fully finish migration and remove duplication:
|
|
||||||
|
|
||||||
1. Move API auth/session checks to Supabase JWT/session directly.
|
|
||||||
2. Remove custom session table/cookie flow (`public.sessions`, `gantt_session`) after cutover.
|
|
||||||
3. Keep `public.profiles` as the only app profile table and retire compatibility mirrors.
|
|
||||||
24
README.md
24
README.md
@ -1,12 +1,12 @@
|
|||||||
# Gantt Board
|
# Gantt Board
|
||||||
|
|
||||||
Task and sprint board built with Next.js + Zustand and Supabase-backed API persistence.
|
Task and sprint board built with Next.js + Zustand and SQLite-backed API persistence.
|
||||||
|
|
||||||
## Current Product Behavior
|
## Current Product Behavior
|
||||||
|
|
||||||
### Feb 20, 2026 updates
|
### Feb 20, 2026 updates
|
||||||
|
|
||||||
- Added task attachments with Supabase persistence.
|
- Added task attachments with SQLite persistence.
|
||||||
- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link.
|
- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link.
|
||||||
- Replaced flat notes/comments with threaded comments.
|
- Replaced flat notes/comments with threaded comments.
|
||||||
- Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
|
- Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
|
||||||
@ -14,15 +14,6 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing.
|
- Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing.
|
||||||
- Added lightweight collaborator identity tracking for task/comment authorship.
|
- Added lightweight collaborator identity tracking for task/comment authorship.
|
||||||
|
|
||||||
### Feb 21, 2026 updates
|
|
||||||
|
|
||||||
- Stabilized auth and settings migration after SQLite -> Supabase transition.
|
|
||||||
- Login/password validation now uses Supabase Auth credentials.
|
|
||||||
- Reset password flow no longer depends on `public.users.password_hash`.
|
|
||||||
- Settings API response handling was hardened to avoid client script errors.
|
|
||||||
- Avatar preset key collision warning in settings was fixed.
|
|
||||||
- Detailed notes: `AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md`
|
|
||||||
|
|
||||||
### Data model and status rules
|
### Data model and status rules
|
||||||
|
|
||||||
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
||||||
@ -60,7 +51,6 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Added `Remember me` in auth forms:
|
- Added `Remember me` in auth forms:
|
||||||
- Checked: persistent 30-day cookie/session.
|
- Checked: persistent 30-day cookie/session.
|
||||||
- Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry.
|
- Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry.
|
||||||
- Forgot-password currently uses a dev reset-link flow unless an external email provider is integrated.
|
|
||||||
- Task/comment authorship now uses authenticated user identity.
|
- Task/comment authorship now uses authenticated user identity.
|
||||||
- Added `GET /api/auth/users` so task forms can load assignable users.
|
- Added `GET /api/auth/users` so task forms can load assignable users.
|
||||||
- User records now include optional `avatarUrl` profile photos.
|
- User records now include optional `avatarUrl` profile photos.
|
||||||
@ -88,7 +78,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Assignee pills on Kanban and Backlog
|
- Assignee pills on Kanban and Backlog
|
||||||
- Threaded comment author avatars
|
- Threaded comment author avatars
|
||||||
- Creator/updater identity rows on task detail views
|
- Creator/updater identity rows on task detail views
|
||||||
- Avatar updates persist in Supabase profile tables and propagate through session/user APIs.
|
- Avatar updates persist in SQLite and propagate through session/user APIs.
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
@ -112,7 +102,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
### Attachments
|
### Attachments
|
||||||
|
|
||||||
- Task detail page supports adding multiple attachments per task.
|
- Task detail page supports adding multiple attachments per task.
|
||||||
- Attachments are stored with each task in Supabase and survive refresh/restart.
|
- Attachments are stored with each task in SQLite and survive refresh/restart.
|
||||||
- Attachment UI supports:
|
- Attachment UI supports:
|
||||||
- Upload multiple files
|
- Upload multiple files
|
||||||
- Open/view file
|
- Open/view file
|
||||||
@ -131,7 +121,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Reply to any comment
|
- Reply to any comment
|
||||||
- Reply to replies recursively (unlimited depth)
|
- Reply to replies recursively (unlimited depth)
|
||||||
- Delete any comment or reply from the thread
|
- Delete any comment or reply from the thread
|
||||||
- Thread data is persisted in Supabase through the existing `/api/tasks` sync flow.
|
- Thread data is persisted in SQLite through the existing `/api/tasks` sync flow.
|
||||||
|
|
||||||
### Backlog drag and drop
|
### Backlog drag and drop
|
||||||
|
|
||||||
@ -145,7 +135,7 @@ Status behavior on drop:
|
|||||||
- Drop into backlog section: `status -> open`
|
- Drop into backlog section: `status -> open`
|
||||||
- `sprintId` is set/cleared based on destination
|
- `sprintId` is set/cleared based on destination
|
||||||
|
|
||||||
Changes persist through store sync to Supabase.
|
Changes persist through store sync to SQLite.
|
||||||
|
|
||||||
### Kanban drag and drop
|
### Kanban drag and drop
|
||||||
|
|
||||||
@ -175,7 +165,7 @@ During drag, the active target column shows expanded status drop zones for clari
|
|||||||
|
|
||||||
- Client state is managed with Zustand.
|
- Client state is managed with Zustand.
|
||||||
- Persistence is done via `/api/tasks`.
|
- Persistence is done via `/api/tasks`.
|
||||||
- API reads/writes Supabase tables.
|
- API reads/writes `data/tasks.db` (SQLite).
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createHash } from "crypto";
|
import { createHash, randomBytes, scryptSync } from "crypto";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -8,6 +8,12 @@ function hashToken(token: string): string {
|
|||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashPassword(password: string, salt?: string): string {
|
||||||
|
const safeSalt = salt || randomBytes(16).toString("hex");
|
||||||
|
const derived = scryptSync(password, safeSalt, 64).toString("hex");
|
||||||
|
return `scrypt$${safeSalt}$${derived}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as {
|
const body = (await request.json()) as {
|
||||||
@ -41,7 +47,7 @@ export async function POST(request: Request) {
|
|||||||
// Find valid token with user info
|
// Find valid token with user info
|
||||||
const { data: resetToken } = await supabase
|
const { data: resetToken } = await supabase
|
||||||
.from("password_reset_tokens")
|
.from("password_reset_tokens")
|
||||||
.select("id, user_id, users(email, name)")
|
.select("id, user_id, users(email)")
|
||||||
.eq("token_hash", tokenHash)
|
.eq("token_hash", tokenHash)
|
||||||
.eq("used", false)
|
.eq("used", false)
|
||||||
.gt("expires_at", now)
|
.gt("expires_at", now)
|
||||||
@ -58,37 +64,23 @@ export async function POST(request: Request) {
|
|||||||
const userEmail = Array.isArray(resetToken.users)
|
const userEmail = Array.isArray(resetToken.users)
|
||||||
? resetToken.users[0]?.email
|
? resetToken.users[0]?.email
|
||||||
: resetToken.users?.email;
|
: resetToken.users?.email;
|
||||||
const userName = Array.isArray(resetToken.users)
|
|
||||||
? resetToken.users[0]?.name
|
|
||||||
: resetToken.users?.name;
|
|
||||||
|
|
||||||
if (userEmail?.toLowerCase() !== email) {
|
if (userEmail?.toLowerCase() !== email) {
|
||||||
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Supabase Auth password. If auth user doesn't exist yet (legacy migration),
|
// Hash new password
|
||||||
// create it with the same UUID so app foreign keys remain valid.
|
const passwordHash = hashPassword(password);
|
||||||
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
|
|
||||||
password,
|
// Update user password
|
||||||
});
|
const { error: updateError } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.update({ password_hash: passwordHash })
|
||||||
|
.eq("id", resetToken.user_id);
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
const updateMessage = updateError.message || "";
|
|
||||||
if (updateMessage.toLowerCase().includes("not found")) {
|
|
||||||
const { error: createError } = await supabase.auth.admin.createUser({
|
|
||||||
id: resetToken.user_id,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
email_confirm: true,
|
|
||||||
user_metadata: {
|
|
||||||
name: typeof userName === "string" && userName.trim().length > 0 ? userName : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (createError) throw createError;
|
|
||||||
} else {
|
|
||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Mark token as used
|
// Mark token as used
|
||||||
await supabase
|
await supabase
|
||||||
|
|||||||
@ -47,29 +47,19 @@ export default function SettingsPage() {
|
|||||||
if (isMounted) router.replace("/login")
|
if (isMounted) router.replace("/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await res.json().catch(() => null)
|
const data = await res.json()
|
||||||
const user = data?.user
|
|
||||||
if (
|
|
||||||
!user
|
|
||||||
|| typeof user.id !== "string"
|
|
||||||
|| typeof user.name !== "string"
|
|
||||||
|| typeof user.email !== "string"
|
|
||||||
) {
|
|
||||||
if (isMounted) router.replace("/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isMounted) return
|
if (!isMounted) return
|
||||||
|
|
||||||
setName(user.name || "")
|
setName(data.user.name || "")
|
||||||
setEmail(user.email || "")
|
setEmail(data.user.email || "")
|
||||||
setProfileAvatarUrl(typeof user.avatarUrl === "string" ? user.avatarUrl : "")
|
setProfileAvatarUrl(data.user.avatarUrl || "")
|
||||||
setCurrentUserId(user.id || "")
|
setCurrentUserId(data.user.id || "")
|
||||||
setInitialEmail(user.email || "")
|
setInitialEmail(data.user.email || "")
|
||||||
setCurrentUser({
|
setCurrentUser({
|
||||||
id: user.id,
|
id: data.user.id,
|
||||||
name: user.name,
|
name: data.user.name,
|
||||||
email: user.email,
|
email: data.user.email,
|
||||||
avatarUrl: typeof user.avatarUrl === "string" ? user.avatarUrl : undefined,
|
avatarUrl: data.user.avatarUrl,
|
||||||
})
|
})
|
||||||
setAuthReady(true)
|
setAuthReady(true)
|
||||||
} catch {
|
} catch {
|
||||||
@ -121,35 +111,23 @@ export default function SettingsPage() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json().catch(() => null)
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setProfileError(
|
setProfileError(data.error || "Failed to update profile")
|
||||||
data && typeof data.error === "string" ? data.error : "Failed to update profile"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const user = data?.user
|
|
||||||
if (
|
|
||||||
!user
|
|
||||||
|| typeof user.id !== "string"
|
|
||||||
|| typeof user.name !== "string"
|
|
||||||
|| typeof user.email !== "string"
|
|
||||||
) {
|
|
||||||
setProfileError("Profile update returned invalid data")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentUser({
|
setCurrentUser({
|
||||||
id: user.id,
|
id: data.user.id,
|
||||||
name: user.name,
|
name: data.user.name,
|
||||||
email: user.email,
|
email: data.user.email,
|
||||||
avatarUrl: typeof user.avatarUrl === "string" ? user.avatarUrl : undefined,
|
avatarUrl: data.user.avatarUrl,
|
||||||
})
|
})
|
||||||
setName(user.name)
|
setName(data.user.name)
|
||||||
setEmail(user.email)
|
setEmail(data.user.email)
|
||||||
setProfileAvatarUrl(typeof user.avatarUrl === "string" ? user.avatarUrl : "")
|
setProfileAvatarUrl(data.user.avatarUrl || "")
|
||||||
setCurrentUserId(user.id)
|
setCurrentUserId(data.user.id)
|
||||||
setInitialEmail(user.email)
|
setInitialEmail(data.user.email)
|
||||||
setProfileCurrentPassword("")
|
setProfileCurrentPassword("")
|
||||||
setProfileSuccess("Profile updated")
|
setProfileSuccess("Profile updated")
|
||||||
} catch {
|
} catch {
|
||||||
@ -183,11 +161,7 @@ export default function SettingsPage() {
|
|||||||
[avatarSeed, name, email]
|
[avatarSeed, name, email]
|
||||||
)
|
)
|
||||||
const avatarPresets = useMemo(
|
const avatarPresets = useMemo(
|
||||||
() =>
|
() => buildAvatarPresets(avatarSeed, name || email || "User", 8),
|
||||||
Array.from(new Set(buildAvatarPresets(avatarSeed, name || email || "User", 8))).map((url, index) => ({
|
|
||||||
id: index,
|
|
||||||
url,
|
|
||||||
})),
|
|
||||||
[avatarSeed, name, email]
|
[avatarSeed, name, email]
|
||||||
)
|
)
|
||||||
const profilePreviewAvatarUrl = profileAvatarUrl || generatedAvatarUrl
|
const profilePreviewAvatarUrl = profileAvatarUrl || generatedAvatarUrl
|
||||||
@ -220,11 +194,9 @@ export default function SettingsPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json().catch(() => null)
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setPasswordError(
|
setPasswordError(data.error || "Failed to update password")
|
||||||
data && typeof data.error === "string" ? data.error : "Failed to update password"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,17 +293,17 @@ export default function SettingsPage() {
|
|||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="text-xs text-slate-400 mb-2">Or pick a preset:</p>
|
<p className="text-xs text-slate-400 mb-2">Or pick a preset:</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{avatarPresets.map((preset) => (
|
{avatarPresets.map((presetUrl, index) => (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={presetUrl}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setProfileAvatarUrl(preset.url)}
|
onClick={() => setProfileAvatarUrl(presetUrl)}
|
||||||
className={`rounded-full p-0.5 border ${profileAvatarUrl === preset.url ? "border-blue-400" : "border-slate-700 hover:border-slate-500"}`}
|
className={`rounded-full p-0.5 border ${profileAvatarUrl === presetUrl ? "border-blue-400" : "border-slate-700 hover:border-slate-500"}`}
|
||||||
title={`Preset ${preset.id + 1}`}
|
title={`Preset ${index + 1}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={preset.url}
|
src={presetUrl}
|
||||||
alt={`Preset avatar ${preset.id + 1}`}
|
alt={`Preset avatar ${index + 1}`}
|
||||||
className="h-8 w-8 rounded-full object-cover"
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { randomBytes, createHash } from "crypto";
|
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import type { Database } from "@/lib/supabase/database.types";
|
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = "gantt_session";
|
const SESSION_COOKIE_NAME = "gantt_session";
|
||||||
const SESSION_HOURS_SHORT = 12;
|
const SESSION_HOURS_SHORT = 12;
|
||||||
@ -21,24 +19,10 @@ interface UserRow {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
password_hash: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPublicSupabase() {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
throw new Error("Missing Supabase public environment variables");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
|
||||||
auth: {
|
|
||||||
autoRefreshToken: false,
|
|
||||||
persistSession: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEmail(email: string): string {
|
function normalizeEmail(email: string): string {
|
||||||
return email.trim().toLowerCase();
|
return email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@ -56,11 +40,22 @@ function normalizeAvatarUrl(value: string | null | undefined): string | undefine
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackNameFromEmail(email: string, preferredName?: string | null): string {
|
function hashPassword(password: string, salt?: string): string {
|
||||||
const trimmed = (preferredName || "").trim();
|
const safeSalt = salt || randomBytes(16).toString("hex");
|
||||||
if (trimmed.length >= 2) return trimmed;
|
const derived = scryptSync(password, safeSalt, 64).toString("hex");
|
||||||
const base = email.split("@")[0]?.trim() || "User";
|
return `scrypt$${safeSalt}$${derived}`;
|
||||||
return base.length >= 2 ? base : "User";
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||||
|
const [, salt, digest] = parts;
|
||||||
|
const candidate = hashPassword(password, salt);
|
||||||
|
const candidateDigest = candidate.split("$")[2];
|
||||||
|
const a = Buffer.from(digest, "hex");
|
||||||
|
const b = Buffer.from(candidateDigest, "hex");
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashSessionToken(token: string): string {
|
function hashSessionToken(token: string): string {
|
||||||
@ -89,42 +84,6 @@ function mapUserRow(row: UserRow): AuthUser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertUserMirrors(params: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
}) {
|
|
||||||
const supabase = getServiceSupabase();
|
|
||||||
const { error: userError } = await supabase.from("users").upsert(
|
|
||||||
{
|
|
||||||
id: params.id,
|
|
||||||
name: params.name,
|
|
||||||
email: normalizeEmail(params.email),
|
|
||||||
avatar_url: params.avatarUrl ?? null,
|
|
||||||
},
|
|
||||||
{ onConflict: "id" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userError) {
|
|
||||||
throw new Error(userError.message || "Failed to sync user profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: profileError } = await supabase.from("profiles").upsert(
|
|
||||||
{
|
|
||||||
id: params.id,
|
|
||||||
name: params.name,
|
|
||||||
avatar_url: params.avatarUrl ?? null,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ onConflict: "id" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (profileError) {
|
|
||||||
console.error("Failed to sync profiles mirror:", profileError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerUser(params: {
|
export async function registerUser(params: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -141,41 +100,34 @@ export async function registerUser(params: {
|
|||||||
if (password.length < 8) throw new Error("Password must be at least 8 characters");
|
if (password.length < 8) throw new Error("Password must be at least 8 characters");
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const { data, error } = await supabase.auth.admin.createUser({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
email_confirm: true,
|
|
||||||
user_metadata: { name },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data.user) {
|
// Check if email already exists
|
||||||
const message = error?.message || "Failed to create user";
|
const { data: existing } = await supabase
|
||||||
if (message.toLowerCase().includes("already")) {
|
.from("users")
|
||||||
|
.select("id")
|
||||||
|
.eq("email", email)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
throw new Error("Email already exists");
|
throw new Error("Email already exists");
|
||||||
}
|
}
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authUser = data.user;
|
// Create user
|
||||||
const userName = fallbackNameFromEmail(email, name);
|
const { data: user, error } = await supabase
|
||||||
await upsertUserMirrors({
|
|
||||||
id: authUser.id,
|
|
||||||
name: userName,
|
|
||||||
email,
|
|
||||||
avatarUrl: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: userRow, error: userError } = await supabase
|
|
||||||
.from("users")
|
.from("users")
|
||||||
|
.insert({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password_hash: hashPassword(password),
|
||||||
|
})
|
||||||
.select("id, name, email, avatar_url, created_at")
|
.select("id, name, email, avatar_url, created_at")
|
||||||
.eq("id", authUser.id)
|
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (userError || !userRow) {
|
if (error || !user) {
|
||||||
throw new Error(userError?.message || "Failed to load user profile");
|
throw new Error(error?.message || "Failed to create user");
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapUserRow(userRow as UserRow);
|
return mapUserRow(user as UserRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticateUser(params: {
|
export async function authenticateUser(params: {
|
||||||
@ -185,42 +137,16 @@ export async function authenticateUser(params: {
|
|||||||
await deleteExpiredSessions();
|
await deleteExpiredSessions();
|
||||||
|
|
||||||
const email = normalizeEmail(params.email);
|
const email = normalizeEmail(params.email);
|
||||||
const authClient = getPublicSupabase();
|
|
||||||
const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password: params.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signInError || !signInData.user) return null;
|
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const authUser = signInData.user;
|
|
||||||
|
|
||||||
const { data: existingRow } = await supabase
|
|
||||||
.from("users")
|
|
||||||
.select("id, name, email, avatar_url, created_at")
|
|
||||||
.eq("id", authUser.id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
const userName = fallbackNameFromEmail(
|
|
||||||
authUser.email || email,
|
|
||||||
typeof authUser.user_metadata?.name === "string" ? authUser.user_metadata.name : existingRow?.name
|
|
||||||
);
|
|
||||||
|
|
||||||
await upsertUserMirrors({
|
|
||||||
id: authUser.id,
|
|
||||||
name: userName,
|
|
||||||
email: authUser.email || email,
|
|
||||||
avatarUrl: existingRow?.avatar_url || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: row } = await supabase
|
const { data: row } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
.select("id, name, email, avatar_url, created_at")
|
.select("id, name, email, avatar_url, password_hash, created_at")
|
||||||
.eq("id", authUser.id)
|
.eq("email", email)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
if (!verifyPassword(params.password, row.password_hash)) return null;
|
||||||
|
|
||||||
return mapUserRow(row as UserRow);
|
return mapUserRow(row as UserRow);
|
||||||
}
|
}
|
||||||
@ -240,7 +166,7 @@ export async function updateUserAccount(params: {
|
|||||||
// Get current user data
|
// Get current user data
|
||||||
const { data: row } = await supabase
|
const { data: row } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
.select("id, name, email, avatar_url, created_at")
|
.select("id, name, email, avatar_url, password_hash, created_at")
|
||||||
.eq("id", params.userId)
|
.eq("id", params.userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -263,12 +189,7 @@ export async function updateUserAccount(params: {
|
|||||||
|
|
||||||
if (needsPasswordCheck) {
|
if (needsPasswordCheck) {
|
||||||
if (!currentPassword) throw new Error("Current password is required");
|
if (!currentPassword) throw new Error("Current password is required");
|
||||||
const authClient = getPublicSupabase();
|
if (!verifyPassword(currentPassword, row.password_hash)) {
|
||||||
const { error: authError } = await authClient.auth.signInWithPassword({
|
|
||||||
email: row.email,
|
|
||||||
password: currentPassword,
|
|
||||||
});
|
|
||||||
if (authError) {
|
|
||||||
throw new Error("Current password is incorrect");
|
throw new Error("Current password is incorrect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -284,27 +205,7 @@ export async function updateUserAccount(params: {
|
|||||||
if (existing) throw new Error("Email already exists");
|
if (existing) throw new Error("Email already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emailChanged || passwordChanged || requestedName !== row.name) {
|
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.password_hash;
|
||||||
const updatePayload: {
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
user_metadata?: { name: string };
|
|
||||||
} = {
|
|
||||||
user_metadata: { name: requestedName },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (emailChanged) updatePayload.email = requestedEmail;
|
|
||||||
if (passwordChanged) updatePayload.password = newPassword;
|
|
||||||
|
|
||||||
const { error: authUpdateError } = await supabase.auth.admin.updateUserById(row.id, updatePayload);
|
|
||||||
if (authUpdateError) {
|
|
||||||
const message = authUpdateError.message || "Failed to update account";
|
|
||||||
if (message.toLowerCase().includes("already")) {
|
|
||||||
throw new Error("Email already exists");
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: updated, error } = await supabase
|
const { data: updated, error } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
@ -312,6 +213,7 @@ export async function updateUserAccount(params: {
|
|||||||
name: requestedName,
|
name: requestedName,
|
||||||
email: requestedEmail,
|
email: requestedEmail,
|
||||||
avatar_url: requestedAvatar ?? null,
|
avatar_url: requestedAvatar ?? null,
|
||||||
|
password_hash: nextPasswordHash,
|
||||||
})
|
})
|
||||||
.eq("id", row.id)
|
.eq("id", row.id)
|
||||||
.select("id, name, email, avatar_url, created_at")
|
.select("id, name, email, avatar_url, created_at")
|
||||||
@ -321,20 +223,6 @@ export async function updateUserAccount(params: {
|
|||||||
throw new Error(error?.message || "Failed to update user");
|
throw new Error(error?.message || "Failed to update user");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: profileError } = await supabase.from("profiles").upsert(
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
name: requestedName,
|
|
||||||
avatar_url: requestedAvatar ?? null,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ onConflict: "id" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (profileError) {
|
|
||||||
console.error("Failed to sync profiles mirror:", profileError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserRow(updated as UserRow);
|
return mapUserRow(updated as UserRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user