feat(auth): 6-character password minimum, set in one place

PASSWORD_MIN_LENGTH (session-policy.ts) now drives every check: the
change-password and set-password screens and routes, admin add/edit,
and the welcome email copy. Previously 10 for self-chosen and 8 for
admin-set. PocketBase's users password field min is already 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-11-02 22:01:06 +02:00
parent dcb9a5d098
commit 136c318fa7
9 changed files with 28 additions and 18 deletions

View file

@ -3,6 +3,7 @@ import { pbAdmin } from "@/lib/pb";
import { authMode } from "@/lib/auth/session";
import { isRole } from "@/lib/auth/access";
import { parseUserAccess } from "@/lib/auth/user-input";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/auth/session-policy";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
if (authMode() !== "users") return Response.json({ error: "Not found" }, { status: 404 });
@ -36,8 +37,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
payload.must_change_password = body.must_change_password;
}
if (body.password !== undefined) {
if (typeof body.password !== "string" || body.password.length < 8 || body.password.length > 128) {
return Response.json({ error: "Password must have 8–128 characters" }, { status: 400 });
if (typeof body.password !== "string" || body.password.length < PASSWORD_MIN_LENGTH || body.password.length > PASSWORD_MAX_LENGTH) {
return Response.json({ error: `Password must have ${PASSWORD_MIN_LENGTH}–${PASSWORD_MAX_LENGTH} characters` }, { status: 400 });
}
payload.password = body.password;
payload.passwordConfirm = body.password;

View file

@ -3,7 +3,7 @@ import PocketBase from "pocketbase";
import { initialDestination } from "@/lib/auth/access";
import { authMode, getCurrentUser, SESSION_COOKIE } from "@/lib/auth/session";
import { PB_URL, pbAdmin } from "@/lib/pb";
import { SESSION_MAX_AGE_SECONDS } from "@/lib/auth/session-policy";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, SESSION_MAX_AGE_SECONDS } from "@/lib/auth/session-policy";
export async function POST(request: Request): Promise<Response> {
if (authMode() !== "users") return Response.json({ error: "Not found" }, { status: 404 });
@ -16,8 +16,8 @@ export async function POST(request: Request): Promise<Response> {
catch { return Response.json({ error: "Enter and confirm your new password" }, { status: 400 }); }
const password = typeof body.password === "string" ? body.password : "";
const passwordConfirm = typeof body.passwordConfirm === "string" ? body.passwordConfirm : "";
if (password.length < 10 || password.length > 128 || password !== passwordConfirm) {
return Response.json({ error: "Use at least 10 characters and confirm the same password" }, { status: 400 });
if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH || password !== passwordConfirm) {
return Response.json({ error: `Use at least ${PASSWORD_MIN_LENGTH} characters and confirm the same password` }, { status: 400 });
}
try {

View file

@ -4,7 +4,7 @@ import { initialDestination, userFromRecord } from "@/lib/auth/access";
import { authMode, SESSION_COOKIE } from "@/lib/auth/session";
import { findValidLink, setPasswordAndBurnLink } from "@/lib/auth/password-links";
import { PB_URL, pbAdmin } from "@/lib/pb";
import { SESSION_MAX_AGE_SECONDS } from "@/lib/auth/session-policy";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, SESSION_MAX_AGE_SECONDS } from "@/lib/auth/session-policy";
export async function POST(request: Request): Promise<Response> {
if (authMode() !== "users") return Response.json({ error: "Not found" }, { status: 404 });
@ -15,8 +15,8 @@ export async function POST(request: Request): Promise<Response> {
const token = typeof body.token === "string" ? body.token : "";
const password = typeof body.password === "string" ? body.password : "";
const passwordConfirm = typeof body.passwordConfirm === "string" ? body.passwordConfirm : "";
if (password.length < 10 || password.length > 128 || password !== passwordConfirm) {
return Response.json({ error: "Use at least 10 characters and confirm the same password" }, { status: 400 });
if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH || password !== passwordConfirm) {
return Response.json({ error: `Use at least ${PASSWORD_MIN_LENGTH} characters and confirm the same password` }, { status: 400 });
}
const pb = await pbAdmin();

View file

@ -3,6 +3,7 @@
import * as React from "react";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/auth/session-policy";
export function PasswordForm({ name }: { name: string }) {
const [password, setPassword] = React.useState("");
@ -39,16 +40,16 @@ export function PasswordForm({ name }: { name: string }) {
<p className="mt-2 text-sm text-muted-foreground">Welcome, {name}. Set a new password before continuing.</p>
</div>
<label className="block text-sm font-medium">New password
<input type="password" required minLength={10} maxLength={128} autoComplete="new-password"
<input type="password" required minLength={PASSWORD_MIN_LENGTH} maxLength={PASSWORD_MAX_LENGTH} autoComplete="new-password"
value={password} onChange={(event) => setPassword(event.target.value)}
className="mt-2 w-full rounded-md border border-input bg-background px-3 py-2.5" />
</label>
<label className="block text-sm font-medium">Confirm new password
<input type="password" required minLength={10} maxLength={128} autoComplete="new-password"
<input type="password" required minLength={PASSWORD_MIN_LENGTH} maxLength={PASSWORD_MAX_LENGTH} autoComplete="new-password"
value={confirm} onChange={(event) => setConfirm(event.target.value)}
className="mt-2 w-full rounded-md border border-input bg-background px-3 py-2.5" />
</label>
<p className="text-xs text-muted-foreground">Use at least 10 characters. You will not be asked again after saving.</p>
<p className="text-xs text-muted-foreground">Use at least {PASSWORD_MIN_LENGTH} characters. You will not be asked again after saving.</p>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={busy || password !== confirm}>
{busy ? "Saving…" : "Save password and continue"}

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { TableActions } from "@/components/table-actions";
import { Button } from "@/components/ui/button";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/auth/session-policy";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -396,7 +397,7 @@ function UserDialog({ open, onOpenChange, user, sites, isAdmin, onWarning }: {
<div className="mt-2 space-y-2">{sites.map((site) => <label key={site.id} className="flex min-h-11 items-center gap-3 py-1 text-sm"><input className="h-5 w-5 shrink-0 accent-primary" type="checkbox" checked={chosenSites.includes(site.id)} onChange={() => toggleSite(site.id)} />{site.name}</label>)}</div>
</fieldset>}
<label className="block text-sm font-medium">{user ? "Set a new password (optional)" : "Initial password"}
<input type="text" minLength={8} maxLength={128} required={!user} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="off" className="mt-1 min-h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-base sm:min-h-10 sm:text-sm" />
<input type="text" minLength={PASSWORD_MIN_LENGTH} maxLength={PASSWORD_MAX_LENGTH} required={!user} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="off" className="mt-1 min-h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-base sm:min-h-10 sm:text-sm" />
</label>
{user && isAdmin && <fieldset className="min-w-0 rounded-lg border border-border p-3">
<legend className="px-1 text-sm font-medium">Cash float card</legend>

View file

@ -4,6 +4,7 @@ import * as React from "react";
import Link from "next/link";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/auth/session-policy";
export function SetPasswordForm({ token }: { token: string }) {
const [password, setPassword] = React.useState("");
@ -47,16 +48,16 @@ export function SetPasswordForm({ token }: { token: string }) {
<p className="mt-2 text-sm text-muted-foreground">Pick a password for your MEGA account. You will use it every time you sign in.</p>
</div>
<label className="block text-sm font-medium">New password
<input type="password" required minLength={10} maxLength={128} autoComplete="new-password"
<input type="password" required minLength={PASSWORD_MIN_LENGTH} maxLength={PASSWORD_MAX_LENGTH} autoComplete="new-password"
value={password} onChange={(event) => setPassword(event.target.value)}
className="mt-2 w-full rounded-md border border-input bg-background px-3 py-2.5" />
</label>
<label className="block text-sm font-medium">Confirm new password
<input type="password" required minLength={10} maxLength={128} autoComplete="new-password"
<input type="password" required minLength={PASSWORD_MIN_LENGTH} maxLength={PASSWORD_MAX_LENGTH} autoComplete="new-password"
value={confirm} onChange={(event) => setConfirm(event.target.value)}
className="mt-2 w-full rounded-md border border-input bg-background px-3 py-2.5" />
</label>
<p className="text-xs text-muted-foreground">Use at least 10 characters. Pick something only you know.</p>
<p className="text-xs text-muted-foreground">Use at least {PASSWORD_MIN_LENGTH} characters. Pick something only you know.</p>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={busy || password !== confirm}>
{busy ? "Saving…" : "Save password and continue"}

View file

@ -1,2 +1,6 @@
/** Keep in sync with the PocketBase users collection's authToken.duration. */
export const SESSION_MAX_AGE_SECONDS = 60 * 24 * 60 * 60;
/** Keep in sync with the PocketBase users collection's password field min. */
export const PASSWORD_MIN_LENGTH = 6;
export const PASSWORD_MAX_LENGTH = 128;

View file

@ -1,3 +1,4 @@
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/auth/session-policy";
import { isDashboardModule, isRole, type DashboardModule, type Role } from "./access";
export interface UserAccessPayload {
@ -46,7 +47,7 @@ export function parseNewUser(body: unknown, siteIds: Set<string>): NewUserPayloa
const name = typeof input.name === "string" ? input.name.trim() : "";
const password = input.password === undefined ? "mega2026" : input.password;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !name || name.length > 100) return null;
if (typeof password !== "string" || password.length < 8 || password.length > 128) return null;
if (typeof password !== "string" || password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) return null;
const access = parseUserAccess(input, siteIds);
if (!access) return null;
return {

View file

@ -7,6 +7,7 @@
* Brand colours come from src/app/globals.css: --primary #fdb13c,
* --primary-ink #925e0b.
*/
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/session-policy";
const BRAND = "#fdb13c";
const BRAND_INK = "#925e0b";
@ -111,12 +112,12 @@ export function welcomeEmail({ name, email, roleLabel, link, appUrl, clockUser }
: "MEGA Dashboard is where the office and the sites run the business together: contracts, labour, fuel, plant hours and money, site by site.";
const steps = clockUser
? [
"Open the link below and choose your password (at least 10 characters).",
`Open the link below and choose your password (at least ${PASSWORD_MIN_LENGTH} characters).`,
"Sign in and scan staff, vehicles or delivery notes as you go.",
"Add it to your home screen: on iPhone Safari tap Share, then Add to Home Screen.",
]
: [
"Open the link below and choose your password (at least 10 characters).",
`Open the link below and choose your password (at least ${PASSWORD_MIN_LENGTH} characters).`,
"Sign in with your MEGA account.",
"Add it to your home screen: on iPhone Safari tap Share, then Add to Home Screen.",
];