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:
@@ -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) {
|
||||
|
||||
64
api/src/features/webauthn.js
Normal file
64
api/src/features/webauthn.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user