Add family roles to member management
This commit is contained in:
+24
-13
@@ -16,6 +16,7 @@ import { createLogger } from './logger.js';
|
||||
const log = createLogger('Auth');
|
||||
const router = express.Router();
|
||||
const API_TOKEN_PREFIX = 'oikos_';
|
||||
const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
|
||||
@@ -156,7 +157,7 @@ function authenticateApiToken(req) {
|
||||
|
||||
const tokenHash = hashApiToken(token);
|
||||
const row = db.get().prepare(`
|
||||
SELECT t.*, u.role, u.username, u.display_name, u.avatar_color
|
||||
SELECT t.*, u.role, u.username, u.display_name, u.avatar_color, u.family_role
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.created_by
|
||||
WHERE t.token_hash = ?
|
||||
@@ -176,6 +177,7 @@ function authenticateApiToken(req) {
|
||||
display_name: row.display_name,
|
||||
avatar_color: row.avatar_color,
|
||||
role: row.role,
|
||||
family_role: row.family_role,
|
||||
};
|
||||
return row;
|
||||
}
|
||||
@@ -225,7 +227,7 @@ const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#F
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
* Body: { username: string, password: string }
|
||||
* Response: { user: { id, username, display_name, avatar_color, role } }
|
||||
* Response: { user: { id, username, display_name, avatar_color, role, family_role } }
|
||||
*/
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
@@ -277,6 +279,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
display_name: user.display_name,
|
||||
avatar_color: user.avatar_color,
|
||||
role: user.role,
|
||||
family_role: user.family_role,
|
||||
},
|
||||
csrfToken: req.session.csrfToken,
|
||||
});
|
||||
@@ -344,7 +347,7 @@ router.post('/setup', loginLimiter, async (req, res) => {
|
||||
.run(username, display_name, hash, avatarColor, 'admin');
|
||||
|
||||
res.status(201).json({
|
||||
user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, role: 'admin' },
|
||||
user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, role: 'admin', family_role: 'other' },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message?.includes('UNIQUE constraint')) {
|
||||
@@ -362,7 +365,7 @@ router.post('/setup', loginLimiter, async (req, res) => {
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = db.get()
|
||||
.prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?')
|
||||
.prepare('SELECT id, username, display_name, avatar_color, role, family_role FROM users WHERE id = ?')
|
||||
.get(req.authUserId);
|
||||
|
||||
if (!user) {
|
||||
@@ -404,7 +407,7 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
router.get('/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const users = db.get()
|
||||
.prepare('SELECT id, username, display_name, avatar_color, role, created_at FROM users ORDER BY display_name')
|
||||
.prepare('SELECT id, username, display_name, avatar_color, role, family_role, created_at FROM users ORDER BY display_name')
|
||||
.all();
|
||||
res.json({ data: users });
|
||||
} catch (err) {
|
||||
@@ -488,12 +491,20 @@ router.delete('/api-tokens/:id', requireAuth, requireAdmin, csrfMiddleware, (req
|
||||
/**
|
||||
* POST /api/v1/auth/users
|
||||
* Admin only. Erstellt neues Familienmitglied.
|
||||
* Body: { username, display_name, password, avatar_color?, role? }
|
||||
* Body: { username, display_name, password, avatar_color?, family_role?, system_admin? }
|
||||
* Response: { user: { id, username, display_name, avatar_color, role } }
|
||||
*/
|
||||
router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body;
|
||||
const {
|
||||
username,
|
||||
display_name,
|
||||
password,
|
||||
avatar_color = '#007AFF',
|
||||
family_role = 'other',
|
||||
system_admin = req.body.role === 'admin',
|
||||
} = req.body;
|
||||
const role = system_admin === true || system_admin === 'true' ? 'admin' : 'member';
|
||||
|
||||
if (!username || !display_name || !password) {
|
||||
return res.status(400).json({ error: 'Username, display name, and password are required.', code: 400 });
|
||||
@@ -511,21 +522,21 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
|
||||
return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 });
|
||||
}
|
||||
|
||||
if (!['admin', 'member'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role.', code: 400 });
|
||||
if (!FAMILY_ROLES.includes(family_role)) {
|
||||
return res.status(400).json({ error: 'Invalid family role.', code: 400 });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
const result = db.get()
|
||||
.prepare(`
|
||||
INSERT INTO users (username, display_name, password_hash, avatar_color, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO users (username, display_name, password_hash, avatar_color, role, family_role)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(username, display_name, hash, avatar_color, role);
|
||||
.run(username, display_name, hash, avatar_color, role, family_role);
|
||||
|
||||
res.status(201).json({
|
||||
user: { id: result.lastInsertRowid, username, display_name, avatar_color, role },
|
||||
user: { id: result.lastInsertRowid, username, display_name, avatar_color, role, family_role },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('UNIQUE constraint')) {
|
||||
|
||||
@@ -17,6 +17,8 @@ const MIGRATIONS_SQL = {
|
||||
avatar_color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK(role IN ('admin', 'member')),
|
||||
family_role TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK(family_role IN ('dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
@@ -717,6 +717,16 @@ const MIGRATIONS = [
|
||||
CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 19,
|
||||
description: 'Separate family member role from system access role',
|
||||
up: `
|
||||
ALTER TABLE users ADD COLUMN family_role TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK(family_role IN ('dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_family_role ON users(family_role);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ import weatherRouter from './routes/weather.js';
|
||||
import preferencesRouter from './routes/preferences.js';
|
||||
import remindersRouter from './routes/reminders.js';
|
||||
import searchRouter from './routes/search.js';
|
||||
import familyRouter from './routes/family.js';
|
||||
|
||||
const log = createLogger('Server');
|
||||
const logSync = createLogger('Sync');
|
||||
@@ -203,6 +204,7 @@ app.use('/api/v1/weather', weatherRouter);
|
||||
app.use('/api/v1/preferences', preferencesRouter);
|
||||
app.use('/api/v1/reminders', remindersRouter);
|
||||
app.use('/api/v1/search', searchRouter);
|
||||
app.use('/api/v1/family', familyRouter);
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Health-Check (für Docker)
|
||||
|
||||
+41
-2
@@ -241,6 +241,21 @@ function buildPaths() {
|
||||
params: [idParam('id', 'API token ID')],
|
||||
}),
|
||||
},
|
||||
'/api/v1/family/members': {
|
||||
get: op({
|
||||
summary: 'List family members',
|
||||
tag: 'Family',
|
||||
description: 'Read-only endpoint for family-member profiles. It does not expose usernames or system access roles and does not support create/update/delete operations.',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Family members',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/FamilyMembersResponse' } } },
|
||||
},
|
||||
401: { $ref: '#/components/responses/Unauthorized' },
|
||||
500: { $ref: '#/components/responses/InternalServerError' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
'/api/v1/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) },
|
||||
'/api/v1/tasks': {
|
||||
get: op({ summary: 'List tasks', tag: 'Tasks' }),
|
||||
@@ -443,6 +458,7 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
tags: [
|
||||
{ name: 'System' },
|
||||
{ name: 'Auth' },
|
||||
{ name: 'Family' },
|
||||
{ name: 'Dashboard' },
|
||||
{ name: 'Tasks' },
|
||||
{ name: 'Shopping' },
|
||||
@@ -528,8 +544,30 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
display_name: { type: 'string' },
|
||||
avatar_color: { type: 'string' },
|
||||
role: { type: 'string', enum: ['admin', 'member'] },
|
||||
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
|
||||
},
|
||||
required: ['id', 'username', 'display_name', 'avatar_color', 'role'],
|
||||
required: ['id', 'username', 'display_name', 'avatar_color', 'role', 'family_role'],
|
||||
},
|
||||
FamilyMember: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
display_name: { type: 'string' },
|
||||
avatar_color: { type: 'string' },
|
||||
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'display_name', 'avatar_color', 'family_role'],
|
||||
},
|
||||
FamilyMembersResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/FamilyMember' },
|
||||
},
|
||||
},
|
||||
required: ['data'],
|
||||
},
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
@@ -579,7 +617,8 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
display_name: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
avatar_color: { type: 'string' },
|
||||
role: { type: 'string', enum: ['admin', 'member'] },
|
||||
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
|
||||
system_admin: { type: 'boolean' },
|
||||
},
|
||||
required: ['username', 'display_name', 'password'],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Module: Family
|
||||
* Purpose: Read-only family member API.
|
||||
* Dependencies: express, server/db.js
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import * as db from '../db.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const log = createLogger('Family');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/members', (req, res) => {
|
||||
try {
|
||||
const members = db.get().prepare(`
|
||||
SELECT id, display_name, avatar_color, family_role, created_at
|
||||
FROM users
|
||||
ORDER BY display_name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data: members });
|
||||
} catch (err) {
|
||||
log.error('GET /members error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user