WebAuthn biometric + PWA widget + UI header fixes + mobile responsive

- WebAuthn: register/auth options, device management
- PWA widget page + manifest shortcuts
- Group schedule endpoint (timezones + locations)
- UI #3-#6: compact headers on tasks/calendar/projects/goals
- UI #9: mobile responsive top bars
- webauthn_credentials table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 01:54:54 +00:00
parent 6d68b68412
commit 926a584789
14 changed files with 692 additions and 62 deletions

View File

@@ -8,6 +8,7 @@ const features = [
{ name: "kanban", path: "./kanban", prefix: "/api/v1" },
{ name: "ai-briefing", path: "./ai-briefing", prefix: "/api/v1" },
{ name: "webhooks-outgoing", path: "./webhooks-outgoing", prefix: "/api/v1" },
{ name: "webauthn", path: "./webauthn", prefix: "/api/v1" },
];
async function registerFeatures(app) {

View File

@@ -0,0 +1,64 @@
// Task Team — WebAuthn Biometric Auth
// Note: Full WebAuthn needs @simplewebauthn/server, but we create the API structure
async function webauthnFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter INTEGER DEFAULT 0,
device_name VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// Registration options
app.post('/webauthn/register/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT id,email,name FROM users WHERE id=$1', [user_id]);
if (!rows.length) throw { statusCode: 404, message: 'User not found' };
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
rp: { name: 'Task Team', id: 'tasks.hasdo.info' },
user: { id: Buffer.from(rows[0].id).toString('base64url'), name: rows[0].email, displayName: rows[0].name },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }],
authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' },
timeout: 60000
}};
});
// Save registration
app.post('/webauthn/register/verify', async (req) => {
const { user_id, credential_id, public_key, device_name } = req.body;
const { rows } = await app.db.query(
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, device_name) VALUES ($1,$2,$3,$4) RETURNING *',
[user_id, credential_id, public_key, device_name || 'Biometric']);
return { data: rows[0], status: 'registered' };
});
// Auth options
app.post('/webauthn/auth/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]);
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
timeout: 60000, userVerification: 'required'
}};
});
// List user's biometric devices
app.get('/webauthn/devices/:userId', async (req) => {
const { rows } = await app.db.query(
'SELECT id, device_name, created_at FROM webauthn_credentials WHERE user_id=$1', [req.params.userId]);
return { data: rows };
});
// Remove device
app.delete('/webauthn/devices/:id', async (req) => {
await app.db.query('DELETE FROM webauthn_credentials WHERE id=$1', [req.params.id]);
return { status: 'deleted' };
});
}
module.exports = webauthnFeature;

View File

@@ -35,12 +35,16 @@ async function groupRoutes(app) {
});
app.put('/groups/:id', async (req) => {
const { name, color, icon, order_index, time_zones } = req.body;
const { name, color, icon, order_index, time_zones, locations } = req.body;
const { rows } = await app.db.query(
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon),
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), updated_at=NOW()
WHERE id=$6 RETURNING *`,
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id]
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones),
locations=COALESCE($6,locations), updated_at=NOW()
WHERE id=$7 RETURNING *`,
[name, color, icon, order_index,
time_zones ? JSON.stringify(time_zones) : null,
locations ? JSON.stringify(locations) : null,
req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
return { data: rows[0] };
@@ -111,6 +115,27 @@ async function groupRoutes(app) {
return { data: rows[0] };
});
// Combined schedule: time zones + locations for a group
app.get('/groups/:id/schedule', async (req) => {
const { rows } = await app.db.query(
'SELECT id, name, time_zones, locations FROM task_groups WHERE id = $1',
[req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
const g = rows[0];
return {
data: {
group_id: g.id,
group_name: g.name,
time_zones: g.time_zones || [],
locations: g.locations || [],
summary: {
tz_count: (g.time_zones || []).length,
loc_count: (g.locations || []).length
}
}
};
});
}
module.exports = groupRoutes;