Compare commits

...

3 Commits

5 changed files with 348 additions and 99 deletions

View File

@ -0,0 +1,91 @@
# 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.

View File

@ -1,12 +1,12 @@
# Gantt Board
Task and sprint board built with Next.js + Zustand and SQLite-backed API persistence.
Task and sprint board built with Next.js + Zustand and Supabase-backed API persistence.
## Current Product Behavior
### Feb 20, 2026 updates
- Added task attachments with SQLite persistence.
- Added task attachments with Supabase persistence.
- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link.
- Replaced flat notes/comments with threaded comments.
- Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
@ -14,6 +14,15 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- 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.
### 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
- Tasks use labels (`tags: string[]`) and can have multiple labels.
@ -51,6 +60,7 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- Added `Remember me` in auth forms:
- Checked: persistent 30-day cookie/session.
- 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.
- Added `GET /api/auth/users` so task forms can load assignable users.
- User records now include optional `avatarUrl` profile photos.
@ -78,7 +88,7 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- Assignee pills on Kanban and Backlog
- Threaded comment author avatars
- Creator/updater identity rows on task detail views
- Avatar updates persist in SQLite and propagate through session/user APIs.
- Avatar updates persist in Supabase profile tables and propagate through session/user APIs.
### Labels
@ -102,7 +112,7 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
### Attachments
- Task detail page supports adding multiple attachments per task.
- Attachments are stored with each task in SQLite and survive refresh/restart.
- Attachments are stored with each task in Supabase and survive refresh/restart.
- Attachment UI supports:
- Upload multiple files
- Open/view file
@ -121,7 +131,7 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- Reply to any comment
- Reply to replies recursively (unlimited depth)
- Delete any comment or reply from the thread
- Thread data is persisted in SQLite through the existing `/api/tasks` sync flow.
- Thread data is persisted in Supabase through the existing `/api/tasks` sync flow.
### Backlog drag and drop
@ -135,7 +145,7 @@ Status behavior on drop:
- Drop into backlog section: `status -> open`
- `sprintId` is set/cleared based on destination
Changes persist through store sync to SQLite.
Changes persist through store sync to Supabase.
### Kanban drag and drop
@ -165,7 +175,7 @@ During drag, the active target column shows expanded status drop zones for clari
- Client state is managed with Zustand.
- Persistence is done via `/api/tasks`.
- API reads/writes `data/tasks.db` (SQLite).
- API reads/writes Supabase tables.
## Run locally

View File

@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { createHash, randomBytes, scryptSync } from "crypto";
import { createHash } from "crypto";
import { getServiceSupabase } from "@/lib/supabase/client";
export const runtime = "nodejs";
@ -8,12 +8,6 @@ function hashToken(token: string): string {
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) {
try {
const body = (await request.json()) as {
@ -47,7 +41,7 @@ export async function POST(request: Request) {
// Find valid token with user info
const { data: resetToken } = await supabase
.from("password_reset_tokens")
.select("id, user_id, users(email)")
.select("id, user_id, users(email, name)")
.eq("token_hash", tokenHash)
.eq("used", false)
.gt("expires_at", now)
@ -64,22 +58,36 @@ export async function POST(request: Request) {
const userEmail = Array.isArray(resetToken.users)
? resetToken.users[0]?.email
: resetToken.users?.email;
const userName = Array.isArray(resetToken.users)
? resetToken.users[0]?.name
: resetToken.users?.name;
if (userEmail?.toLowerCase() !== email) {
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
}
// Hash new password
const passwordHash = hashPassword(password);
// Update user password
const { error: updateError } = await supabase
.from("users")
.update({ password_hash: passwordHash })
.eq("id", resetToken.user_id);
// Update Supabase Auth password. If auth user doesn't exist yet (legacy migration),
// create it with the same UUID so app foreign keys remain valid.
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
password,
});
if (updateError) {
throw 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;
}
}
// Mark token as used

View File

@ -47,19 +47,29 @@ export default function SettingsPage() {
if (isMounted) router.replace("/login")
return
}
const data = await res.json()
const data = await res.json().catch(() => null)
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
setName(data.user.name || "")
setEmail(data.user.email || "")
setProfileAvatarUrl(data.user.avatarUrl || "")
setCurrentUserId(data.user.id || "")
setInitialEmail(data.user.email || "")
setName(user.name || "")
setEmail(user.email || "")
setProfileAvatarUrl(typeof user.avatarUrl === "string" ? user.avatarUrl : "")
setCurrentUserId(user.id || "")
setInitialEmail(user.email || "")
setCurrentUser({
id: data.user.id,
name: data.user.name,
email: data.user.email,
avatarUrl: data.user.avatarUrl,
id: user.id,
name: user.name,
email: user.email,
avatarUrl: typeof user.avatarUrl === "string" ? user.avatarUrl : undefined,
})
setAuthReady(true)
} catch {
@ -111,23 +121,35 @@ export default function SettingsPage() {
body: JSON.stringify(payload),
})
const data = await res.json()
const data = await res.json().catch(() => null)
if (!res.ok) {
setProfileError(data.error || "Failed to update profile")
setProfileError(
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
}
setCurrentUser({
id: data.user.id,
name: data.user.name,
email: data.user.email,
avatarUrl: data.user.avatarUrl,
id: user.id,
name: user.name,
email: user.email,
avatarUrl: typeof user.avatarUrl === "string" ? user.avatarUrl : undefined,
})
setName(data.user.name)
setEmail(data.user.email)
setProfileAvatarUrl(data.user.avatarUrl || "")
setCurrentUserId(data.user.id)
setInitialEmail(data.user.email)
setName(user.name)
setEmail(user.email)
setProfileAvatarUrl(typeof user.avatarUrl === "string" ? user.avatarUrl : "")
setCurrentUserId(user.id)
setInitialEmail(user.email)
setProfileCurrentPassword("")
setProfileSuccess("Profile updated")
} catch {
@ -161,7 +183,11 @@ export default function SettingsPage() {
[avatarSeed, name, email]
)
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]
)
const profilePreviewAvatarUrl = profileAvatarUrl || generatedAvatarUrl
@ -194,9 +220,11 @@ export default function SettingsPage() {
}),
})
const data = await res.json()
const data = await res.json().catch(() => null)
if (!res.ok) {
setPasswordError(data.error || "Failed to update password")
setPasswordError(
data && typeof data.error === "string" ? data.error : "Failed to update password"
)
return
}
@ -293,17 +321,17 @@ export default function SettingsPage() {
<div className="mt-3">
<p className="text-xs text-slate-400 mb-2">Or pick a preset:</p>
<div className="flex flex-wrap gap-2">
{avatarPresets.map((presetUrl, index) => (
{avatarPresets.map((preset) => (
<button
key={presetUrl}
key={preset.id}
type="button"
onClick={() => setProfileAvatarUrl(presetUrl)}
className={`rounded-full p-0.5 border ${profileAvatarUrl === presetUrl ? "border-blue-400" : "border-slate-700 hover:border-slate-500"}`}
title={`Preset ${index + 1}`}
onClick={() => setProfileAvatarUrl(preset.url)}
className={`rounded-full p-0.5 border ${profileAvatarUrl === preset.url ? "border-blue-400" : "border-slate-700 hover:border-slate-500"}`}
title={`Preset ${preset.id + 1}`}
>
<img
src={presetUrl}
alt={`Preset avatar ${index + 1}`}
src={preset.url}
alt={`Preset avatar ${preset.id + 1}`}
className="h-8 w-8 rounded-full object-cover"
/>
</button>

View File

@ -1,6 +1,8 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto";
import { randomBytes, createHash } from "crypto";
import { cookies } from "next/headers";
import { createClient } from "@supabase/supabase-js";
import { getServiceSupabase } from "@/lib/supabase/client";
import type { Database } from "@/lib/supabase/database.types";
const SESSION_COOKIE_NAME = "gantt_session";
const SESSION_HOURS_SHORT = 12;
@ -19,10 +21,24 @@ interface UserRow {
name: string;
email: string;
avatar_url: string | null;
password_hash: 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 {
return email.trim().toLowerCase();
}
@ -40,22 +56,11 @@ function normalizeAvatarUrl(value: string | null | undefined): string | undefine
return trimmed;
}
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}`;
}
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 fallbackNameFromEmail(email: string, preferredName?: string | null): string {
const trimmed = (preferredName || "").trim();
if (trimmed.length >= 2) return trimmed;
const base = email.split("@")[0]?.trim() || "User";
return base.length >= 2 ? base : "User";
}
function hashSessionToken(token: string): string {
@ -84,6 +89,42 @@ 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: {
name: string;
email: string;
@ -100,34 +141,41 @@ export async function registerUser(params: {
if (password.length < 8) throw new Error("Password must be at least 8 characters");
const supabase = getServiceSupabase();
const { data, error } = await supabase.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { name },
});
// Check if email already exists
const { data: existing } = await supabase
.from("users")
.select("id")
.eq("email", email)
.maybeSingle();
if (existing) {
throw new Error("Email already exists");
if (error || !data.user) {
const message = error?.message || "Failed to create user";
if (message.toLowerCase().includes("already")) {
throw new Error("Email already exists");
}
throw new Error(message);
}
// Create user
const { data: user, error } = await supabase
const authUser = data.user;
const userName = fallbackNameFromEmail(email, name);
await upsertUserMirrors({
id: authUser.id,
name: userName,
email,
avatarUrl: null,
});
const { data: userRow, error: userError } = await supabase
.from("users")
.insert({
name,
email,
password_hash: hashPassword(password),
})
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.single();
if (error || !user) {
throw new Error(error?.message || "Failed to create user");
if (userError || !userRow) {
throw new Error(userError?.message || "Failed to load user profile");
}
return mapUserRow(user as UserRow);
return mapUserRow(userRow as UserRow);
}
export async function authenticateUser(params: {
@ -137,16 +185,42 @@ export async function authenticateUser(params: {
await deleteExpiredSessions();
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 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
.from("users")
.select("id, name, email, avatar_url, password_hash, created_at")
.eq("email", email)
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.maybeSingle();
if (!row) return null;
if (!verifyPassword(params.password, row.password_hash)) return null;
return mapUserRow(row as UserRow);
}
@ -166,7 +240,7 @@ export async function updateUserAccount(params: {
// Get current user data
const { data: row } = await supabase
.from("users")
.select("id, name, email, avatar_url, password_hash, created_at")
.select("id, name, email, avatar_url, created_at")
.eq("id", params.userId)
.single();
@ -189,7 +263,12 @@ export async function updateUserAccount(params: {
if (needsPasswordCheck) {
if (!currentPassword) throw new Error("Current password is required");
if (!verifyPassword(currentPassword, row.password_hash)) {
const authClient = getPublicSupabase();
const { error: authError } = await authClient.auth.signInWithPassword({
email: row.email,
password: currentPassword,
});
if (authError) {
throw new Error("Current password is incorrect");
}
}
@ -205,7 +284,27 @@ export async function updateUserAccount(params: {
if (existing) throw new Error("Email already exists");
}
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.password_hash;
if (emailChanged || passwordChanged || requestedName !== row.name) {
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
.from("users")
@ -213,7 +312,6 @@ export async function updateUserAccount(params: {
name: requestedName,
email: requestedEmail,
avatar_url: requestedAvatar ?? null,
password_hash: nextPasswordHash,
})
.eq("id", row.id)
.select("id, name, email, avatar_url, created_at")
@ -223,6 +321,20 @@ export async function updateUserAccount(params: {
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);
}