v1.0.0:4 — remove default admin@local credentials; require StartOS action to bootstrap

Security: shipping admin@local / workout123 as a default that the
operator was supposed-to-rotate-but-might-not is the kind of footgun
that turns into "default-credential exposure" headlines. Eliminated.

prisma/seed.ts now ONLY seeds the InstanceSettings singleton — no
admin user, no UserPreferences, no exercises in the build-time
fallback DB. The image still ships with prisma/exercises.seed.json
(curated 164-exercise library) but those rows aren't inserted until
an admin is created via the StartOS Action.

The change-admin-credentials Action now does INSERT-or-UPDATE in one
shot. CREATE mode (no admin exists) inserts the User row, inserts
UserPreferences with sensible defaults, and runs
ensureExerciseLibrary.cjs for the new admin so they don't have to
wait for the next service start to see the curated library. UPDATE
mode (admin exists) keeps the v1.0.0:1-3 rotation behavior. The
mode is auto-detected by counting `WHERE isAdmin = 1`.

The login page is now a server component that reads the admin count
upfront. Zero admins -> renders a "needs setup" panel pointing at
the StartOS Action ("Services -> Proof of Work -> Actions -> Set
admin credentials"). Otherwise renders the existing LoginForm
(extracted to LoginForm.tsx). Eliminates the
"I tried admin@local/workout123 and it failed, what's wrong"
fresh-installer confusion.

Backward compatible for upgrades from v1.0.0:1-3:
  - /data already has an admin user; the no-admin detection never
    triggers; login behaves identically to before.
  - The Action's UPDATE mode still works for rotation.

Version graph: v1.0.0:4 promoted to current; v1.0.0:1, :2, :3 all
listed as `other` for in-place upgrade paths.

README updated to call out the explicit no-default-account design
and how to bootstrap an admin in local dev (Prisma Studio, since
the StartOS action isn't available off-StartOS).
This commit is contained in:
Keysat
2026-05-09 19:13:49 -05:00
parent a64fee4873
commit 5f7b3b6b7a
8 changed files with 405 additions and 266 deletions
@@ -0,0 +1,92 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { loginAction } from './actions';
export default function LoginForm() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await loginAction(email, password);
if (result.error) {
setError(result.error);
setLoading(false);
return;
}
if (result.success) {
router.push('/main/dashboard');
}
} catch (err) {
setError('An unexpected error occurred');
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label
htmlFor="email"
className="text-xs font-semibold text-white uppercase tracking-wider"
>
Email
</label>
<input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
disabled={loading}
/>
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="text-xs font-semibold text-white uppercase tracking-wider"
>
Password
</label>
<input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
disabled={loading}
/>
</div>
{error && (
<div className="rounded bg-red-900/50 px-4 py-3 border border-red-800 text-sm text-red-400">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded bg-white text-black font-bold text-sm uppercase tracking-wider transition-all duration-200 hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
}
+61 -87
View File
@@ -1,38 +1,21 @@
'use client';
import { prisma } from '@/lib/prisma';
import LoginForm from './LoginForm';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { loginAction } from './actions';
/**
* Server component wrapper. Reads admin count BEFORE rendering so that
* a fresh install (no admin yet) shows a clear "needs setup" panel
* instead of a login form that will reject every attempt with no
* explanation.
*
* The "no admin" state is the explicit shipped default as of v1.0.0:4
* — a fresh install ships with zero users, the operator must run the
* StartOS Action "Set admin credentials" before anyone can sign in.
*/
export const dynamic = 'force-dynamic';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await loginAction(email, password);
if (result.error) {
setError(result.error);
setLoading(false);
return;
}
if (result.success) {
router.push('/main/dashboard');
}
} catch (err) {
setError('An unexpected error occurred');
setLoading(false);
}
};
export default async function LoginPage() {
const adminCount = await prisma.user.count({ where: { isAdmin: true } });
const needsSetup = adminCount === 0;
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center px-4">
@@ -43,68 +26,59 @@ export default function LoginPage() {
Proof of Work
</h1>
<p className="text-xs text-zinc-500 mt-2 uppercase tracking-widest">
Track. Lift. Dominate.
{needsSetup ? 'Initial setup required' : 'Track. Lift. Dominate.'}
</p>
</div>
<div className="p-8 pt-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label htmlFor="email" className="text-xs font-semibold text-white uppercase tracking-wider">
Email
</label>
<input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
disabled={loading}
/>
</div>
{needsSetup ? <NeedsSetupPanel /> : <LoginForm />}
<div className="space-y-2">
<label htmlFor="password" className="text-xs font-semibold text-white uppercase tracking-wider">
Password
</label>
<input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
disabled={loading}
/>
</div>
{error && (
<div className="rounded bg-red-900/50 px-4 py-3 border border-red-800 text-sm text-red-400">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded bg-white text-black font-bold text-sm uppercase tracking-wider transition-all duration-200 hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="text-center text-xs text-zinc-500 mt-6">
Don&apos;t have an account?{' '}
<a href="/auth/signup" className="text-white underline hover:text-zinc-300">
Sign up
</a>
</p>
{!needsSetup && (
<p className="text-center text-xs text-zinc-500 mt-6">
Don&apos;t have an account?{' '}
<a
href="/auth/signup"
className="text-white underline hover:text-zinc-300"
>
Sign up
</a>
</p>
)}
</div>
</div>
</div>
</div>
);
}
function NeedsSetupPanel() {
return (
<div className="space-y-4 text-sm text-zinc-300">
<p>
This Proof of Work instance has no admin yet. Logging in or
signing up isn&apos;t possible until the operator creates one.
</p>
<ol className="list-decimal list-inside space-y-2 text-zinc-400">
<li>Open the StartOS UI for this service.</li>
<li>
Make sure the service is{' '}
<span className="text-white font-semibold">stopped</span>.
</li>
<li>
Go to{' '}
<span className="text-white font-semibold">
Actions Set admin credentials
</span>
.
</li>
<li>Enter the admin email + password and submit.</li>
<li>Start the service and reload this page.</li>
</ol>
<p className="text-xs text-zinc-500 pt-2 border-t border-zinc-800">
This is intentional the package ships with no default account
so a forgotten password rotation can&apos;t leave you with
public-default credentials.
</p>
</div>
);
}
+25 -94
View File
@@ -1,112 +1,43 @@
import { PrismaClient } from "@prisma/client";
import * as bcrypt from "bcrypt";
import * as fs from "fs";
import * as path from "path";
/**
* Seeds a fresh database with:
* 1. The default `admin@local` user (password: `workout123`).
* 2. Default UserPreferences for that user.
* 3. The full curated exercise library, loaded from
* `prisma/exercises.seed.json`.
* Seeds a fresh database with ONLY the InstanceSettings singleton.
*
* Idempotent — re-running upserts the user and exercises without
* duplicates. Used at Docker build time to populate the empty-schema
* fallback DB and at first boot for any host that didn't get a baked seed.
* Deliberately does NOT seed an admin user, default credentials, or
* the curated exercise library. As of v1.0.0:4, fresh installs ship
* with zero users — the operator must create the first admin via the
* StartOS Action "Set admin credentials" before anyone can log in.
*
* The curated library is the same JSON read at runtime by
* `ensureExerciseLibrary.cjs` from docker_entrypoint.sh, so updates
* shipped in a new package version reach existing installs too.
* Why: shipping a default `admin@local` / `workout123` is the kind of
* footgun that turns into "default credential exposure" headlines for
* any operator who forgets to rotate. Forcing the StartOS action up
* front means there's literally no usable default to forget about.
*
* The curated exercise library still ships in
* prisma/exercises.seed.json. It gets inserted for the admin at the
* moment they're created (the StartOS action triggers
* ensureExerciseLibrary right after the User INSERT) and then for
* every subsequent user via the boot-time ensure step.
*
* Used at Docker build time to populate the empty-schema fallback DB
* (the one that gets copied into /data on a brand-new sideload).
*/
const prisma = new PrismaClient();
interface LibraryExercise {
name: string;
description: string | null;
type: string;
muscleGroups: string[];
inputFields: string[];
defaultWeightUnit: string | null;
}
function loadLibrary(): LibraryExercise[] {
const libPath = path.resolve(__dirname, "exercises.seed.json");
if (!fs.existsSync(libPath)) {
console.warn(`[seed] library file not found at ${libPath}; seeding 0 exercises`);
return [];
}
const raw = JSON.parse(fs.readFileSync(libPath, "utf8"));
if (!Array.isArray(raw)) {
throw new Error(`[seed] library file at ${libPath} is not an array`);
}
return raw as LibraryExercise[];
}
async function main() {
const hashedPassword = await bcrypt.hash("workout123", 10);
const user = await prisma.user.upsert({
where: { email: "admin@local" },
update: { isAdmin: true },
create: {
email: "admin@local",
passwordHash: hashedPassword,
name: "Admin User",
isAdmin: true,
},
});
console.log("Created/verified admin user:", user.id);
await prisma.instanceSettings.upsert({
where: { id: 1 },
update: {},
create: { id: 1, signupsOpen: false },
});
console.log("Created/verified InstanceSettings (signupsOpen: false)");
await prisma.userPreferences.upsert({
where: { userId: user.id },
update: {},
create: {
userId: user.id,
theme: "system",
defaultWeightUnit: "lbs",
defaultRestSeconds: 90,
enableClaudeAI: false,
},
});
console.log("Created/verified user preferences");
const exercises = loadLibrary();
console.log(`[seed] loading ${exercises.length} exercises from exercises.seed.json`);
for (const exercise of exercises) {
await prisma.exercise.upsert({
where: {
userId_name: {
userId: user.id,
name: exercise.name,
},
},
update: {},
create: {
userId: user.id,
name: exercise.name,
description: exercise.description,
muscleGroups: JSON.stringify(exercise.muscleGroups),
type: exercise.type,
inputFields: JSON.stringify(exercise.inputFields),
defaultWeightUnit: exercise.defaultWeightUnit,
isCustom: false,
},
});
}
console.log(`Created/verified ${exercises.length} exercises`);
console.log(
"[seed] created InstanceSettings singleton (signupsOpen: false)",
);
console.log(
"[seed] no users seeded — operator must run the StartOS Action " +
"'Set admin credentials' to bootstrap the first admin",
);
}
main()