Fix admin seed, open username/email changes, invite refresh & revocation

- Seed checks for any admin role instead of username='admin'
- Username change open to all registered users (was admin-only)
- New change-email endpoint requiring password confirmation
- Settings page: inline editing for username and email
- Invitations: await refresh after generate so list updates visibly
- Invitations: revoke button to delete unused invites (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 22:22:42 -04:00
parent c1cc70eeb9
commit 2912d760cb
6 changed files with 235 additions and 24 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ export async function seedAdmin() {
const [existing] = await db
.select()
.from(users)
.where(eq(users.username, 'admin'))
.where(eq(users.role, 'admin'))
.limit(1);
if (existing) {
+56 -2
View File
@@ -152,8 +152,8 @@ auth.post('/change-password', authMiddleware, async (c) => {
auth.post('/change-username', authMiddleware, async (c) => {
const user = c.get('user');
if (user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Must be registered to change username' }, 403);
}
const { username } = await c.req.json<{ username: string }>();
@@ -180,4 +180,58 @@ auth.post('/change-username', authMiddleware, async (c) => {
return c.json({ success: true, token });
});
auth.post('/change-email', authMiddleware, async (c) => {
const user = c.get('user');
if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Must be registered to change email' }, 403);
}
const { email, currentPassword } = await c.req.json<{
email: string;
currentPassword: string;
}>();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
if (!currentPassword) {
return c.json({ error: 'Current password required' }, 400);
}
const [dbUser] = await db
.select({ passwordHash: users.passwordHash })
.from(users)
.where(eq(users.id, user.id))
.limit(1);
if (!dbUser?.passwordHash) {
return c.json({ error: 'No password set' }, 400);
}
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
if (!valid) {
return c.json({ error: 'Current password is incorrect' }, 401);
}
const existing = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing.length > 0 && existing[0].id !== user.id) {
return c.json({ error: 'Email already in use' }, 409);
}
await db
.update(users)
.set({ email })
.where(eq(users.id, user.id));
const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword);
return c.json({ success: true, token });
});
export { auth };
+22
View File
@@ -141,4 +141,26 @@ invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
return c.json({ invitations: enriched });
});
invitesRouter.delete('/:id', authMiddleware, requireAdmin, async (c) => {
const inviteId = c.req.param('id');
const [invite] = await db
.select({ id: invitations.id, usedBy: invitations.usedBy })
.from(invitations)
.where(eq(invitations.id, inviteId))
.limit(1);
if (!invite) {
return c.json({ error: 'Invitation not found' }, 404);
}
if (invite.usedBy) {
return c.json({ error: 'Cannot revoke a used invitation' }, 400);
}
await db.delete(invitations).where(eq(invitations.id, inviteId));
return c.json({ deleted: true });
});
export { invitesRouter };