chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
Cleanup after Auth.js v5 became the only sign-in path. The platform
had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js
JWT) and a pile of dead-code pages/routes that only existed to support
the legacy path.
What changed:
* getAdminUser() now has only two auth paths:
1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when
ALLOW_DEV_LOGIN is enabled)
2. Auth.js v5 JWT (the encrypted cookie + auth() lookup)
The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch
against admin_users are gone.
* The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS
when set. Unset = open mode (backward compatible with demo/dev). Dev
credentials provider is exempt. The new env var is wired through
.env.example and .gitea/workflows/deploy.yml (read from
secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file).
* change-password/page.tsx now uses auth() server-side instead of
fetching the deleted /api/auth/uid endpoint. The form is split into
page.tsx (server component, auth check) + ChangePasswordForm.tsx
(client component, form state). updatePasswordAction now reads the
user id from auth() instead of the rc_auth_uid cookie.
* Deleted 14 dead-code files:
- Pages: login2, logout, auth/callback, admin/debug-auth,
admin/test-auth
- API routes: api/login, api/logout, api/auth/uid, api/force-admin,
api/set-auth-cookie, api/debug-cookie, api/debug-me,
api/debug-auth
- Actions: src/actions/login.ts
These were the old email/password login, the old Supabase OAuth
callback, the old /api/auth/uid probe, and a pile of debug endpoints
that have been superseded by the new proxy + the new /login page.
* next.config.ts: set outputFileTracingRoot: '.' to silence the
Next.js 16 lockfile-inference warning. Without this the build
walked up from package.json looking for a lockfile, found the
homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json,
and warned on every build. '. resolves to the project root in both
dev and CI, so it's the right answer.
Out of scope (deferred):
* src/actions/admin/users.ts still uses rc_auth_uid internally for its
dev-bypass logic. It works (the rc_auth_uid branch is gated on
NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely
unreachable in production. Clean up in a follow-up.
Pre-flight:
* npx tsc --noEmit: clean
* npm run lint (touched files): clean
* npm run build: clean — proxy picked up, no lockfile warning, all
93 static pages generated.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updatePasswordAction } from "@/actions/admin/password";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
|
||||
export default function ChangePasswordForm({ userId }: { userId: string }) {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const result = await updatePasswordAction(password);
|
||||
setLoading(false);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserActivity({
|
||||
user_id: userId,
|
||||
activity_type: "password_change",
|
||||
details: {},
|
||||
}).catch(() => {});
|
||||
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
||||
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
||||
<svg
|
||||
className="h-6 w-6 text-amber-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
You must set a new password before continuing.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||
placeholder="Min. 8 characters"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||
placeholder="Repeat password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,28 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import ChangePasswordForm from "./ChangePasswordForm";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { updatePasswordAction } from "@/actions/admin/password";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
/**
|
||||
* Forced password-change page.
|
||||
*
|
||||
* Linked from `src/app/admin/layout.tsx:64` when the admin user's row
|
||||
* has `must_change_password = true`. Verifies the Auth.js session
|
||||
* server-side, then renders the form with the user id passed as a prop.
|
||||
*
|
||||
* Previously this page fetched `/api/auth/uid` from a `useEffect` to
|
||||
* resolve the current user. That endpoint (and the underlying
|
||||
* `rc_auth_uid` cookie) has been removed — the Auth.js JWT is the
|
||||
* single source of truth for identity now.
|
||||
*/
|
||||
export default async function ChangePasswordPage() {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/uid")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!data.uid) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
setUserId(data.uid);
|
||||
setCheckingSession(false);
|
||||
}
|
||||
})
|
||||
.catch(() => router.push("/login"));
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const result = await updatePasswordAction(password);
|
||||
setLoading(false);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
logUserActivity({
|
||||
user_id: userId,
|
||||
activity_type: "password_change",
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
if (!userId) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
|
||||
<p className="text-zinc-500 text-sm">Checking session...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
||||
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
||||
<svg className="h-6 w-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
You must set a new password before continuing.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||
placeholder="Min. 8 characters"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||
placeholder="Repeat password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||
<Link
|
||||
href="/logout"
|
||||
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Sign out instead
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return <ChangePasswordForm userId={userId} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user