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:
parent
dcb9a5d098
commit
136c318fa7
9 changed files with 28 additions and 18 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue