feat(teams): temporarily disable Teams API while feature is redesigned

Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.

Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.

Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
This commit is contained in:
ScreenTinker 2026-05-12 13:30:55 -05:00
parent 766f02ae5d
commit 42966da973
2 changed files with 24 additions and 202 deletions

View file

@ -107,7 +107,10 @@
</svg>
<span>Activity</span>
</a></li>
<li><a href="#/teams" class="nav-link" data-view="teams">
<!-- Teams nav hidden while the feature is being redesigned as a user-grouping
primitive within Workspaces. Route + view kept in place so any existing
bookmark still loads (and shows the 503 from the API). -->
<li style="display:none"><a href="#/teams" class="nav-link" data-view="teams">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>

View file

@ -1,207 +1,26 @@
const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const { ELEVATED_ROLES } = require('../middleware/auth');
// List user's teams
router.get('/', (req, res) => {
const teams = db.prepare(`
SELECT t.*, tm.role as my_role,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count
FROM teams t
JOIN team_members tm ON t.id = tm.team_id AND tm.user_id = ?
ORDER BY t.created_at ASC
`).all(req.user.id);
res.json(teams);
});
// Create team
router.post('/', (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
const id = uuidv4();
db.prepare('INSERT INTO teams (id, name, owner_id) VALUES (?, ?, ?)').run(id, name, req.user.id);
db.prepare('INSERT INTO team_members (team_id, user_id, role) VALUES (?, ?, ?)').run(id, req.user.id, 'owner');
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(id);
res.status(201).json(team);
});
// Get team with members
router.get('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!membership && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Not a member' });
team.members = db.prepare(`
SELECT tm.*, u.email, u.name as user_name, u.avatar_url
FROM team_members tm JOIN users u ON tm.user_id = u.id
WHERE tm.team_id = ?
ORDER BY tm.role DESC, tm.joined_at ASC
`).all(req.params.id);
team.invites = db.prepare('SELECT * FROM team_invites WHERE team_id = ? AND expires_at > ?')
.all(req.params.id, Math.floor(Date.now() / 1000));
res.json(team);
});
// Update team
router.put('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id !== req.user.id && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Owner only' });
if (req.body.name) {
db.prepare('UPDATE teams SET name = ? WHERE id = ?').run(req.body.name, req.params.id);
}
res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id));
});
// Delete team
router.delete('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id !== req.user.id && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Owner only' });
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Invite user
router.post('/:id/invite', (req, res) => {
const { email, role } = req.body;
if (!email) return res.status(400).json({ error: 'email required' });
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
// Check if already a member
const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (user) {
const existing = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, user.id);
if (existing) return res.status(409).json({ error: 'Already a member' });
// Direct add if user exists
db.prepare('INSERT INTO team_members (team_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)')
.run(req.params.id, user.id, role || 'viewer', req.user.id);
return res.status(201).json({ success: true, added: true });
}
// Create invite for non-existing user
const id = uuidv4();
const expiresAt = Math.floor(Date.now() / 1000) + 7 * 86400; // 7 days
db.prepare('INSERT INTO team_invites (id, team_id, email, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.params.id, email.toLowerCase(), role || 'viewer', req.user.id, expiresAt);
res.status(201).json({ success: true, invite_id: id, invited: true });
});
// Accept invite
router.post('/accept/:inviteId', (req, res) => {
const invite = db.prepare('SELECT * FROM team_invites WHERE id = ? AND expires_at > ?')
.get(req.params.inviteId, Math.floor(Date.now() / 1000));
if (!invite) return res.status(404).json({ error: 'Invite not found or expired' });
if (invite.email !== req.user.email) return res.status(403).json({ error: 'Invite is for a different email' });
db.prepare('INSERT OR IGNORE INTO team_members (team_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)')
.run(invite.team_id, req.user.id, invite.role, invite.invited_by);
db.prepare('DELETE FROM team_invites WHERE id = ?').run(req.params.inviteId);
res.json({ success: true });
});
// Change member role (owner only)
router.put('/:id/members/:userId', (req, res) => {
const { role } = req.body;
if (!['viewer', 'editor', 'owner'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
// Only team owner or admin can change roles
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!ELEVATED_ROLES.includes(req.user.role) && (!membership || membership.role !== 'owner')) {
return res.status(403).json({ error: 'Only team owner can change roles' });
}
db.prepare('UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?')
.run(role, req.params.id, req.params.userId);
res.json({ success: true });
});
// Remove member (owner only)
router.delete('/:id/members/:userId', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id === req.params.userId) return res.status(400).json({ error: 'Cannot remove owner' });
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!ELEVATED_ROLES.includes(req.user.role) && (!membership || membership.role !== 'owner')) {
return res.status(403).json({ error: 'Only team owner can remove members' });
}
db.prepare('DELETE FROM team_members WHERE team_id = ? AND user_id = ?')
.run(req.params.id, req.params.userId);
res.json({ success: true });
});
// Check team membership or admin role
function checkTeamAccess(req, res) {
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!membership && !ELEVATED_ROLES.includes(req.user.role)) {
res.status(403).json({ error: 'Not a team member' });
return false;
}
return true;
}
// Assign device to team. The caller must own the device (or be an admin) — without
// this check, any team member could pull any device into their team by guessing the
// UUID and then read/control it via the team-membership grants in routes/devices.js.
router.post('/:id/devices', (req, res) => {
if (!checkTeamAccess(req, res)) return;
const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'You do not own this device' });
}
db.prepare('UPDATE devices SET team_id = ? WHERE id = ?').run(req.params.id, device_id);
res.json({ success: true });
});
// Remove device from team. Only the device owner (or an admin) can detach a device
// from a team — otherwise a team member could orphan another user's device.
router.delete('/:id/devices/:deviceId', (req, res) => {
if (!checkTeamAccess(req, res)) return;
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' });
const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'You do not own this device' });
}
db.prepare('UPDATE devices SET team_id = NULL WHERE id = ? AND team_id = ?').run(req.params.deviceId, req.params.id);
res.json({ success: true });
});
// Get team's devices
router.get('/:id/devices', (req, res) => {
if (!checkTeamAccess(req, res)) return;
const devices = db.prepare('SELECT * FROM devices WHERE team_id = ?').all(req.params.id);
res.json(devices);
// Teams API temporarily disabled while the feature is redesigned as a
// user-grouping primitive within the new Workspaces architecture. The
// original Teams data model had no workspace-awareness and was effectively
// non-functional after Phase 2.2 (every resource route migrated away from
// team_id), but the UI remained reachable and let users accumulate orphan
// data while believing they were configuring access control.
//
// All inbound methods now return 503 Service Unavailable with a message
// pointing at the in-progress redesign. The teams / team_members /
// team_invites tables are preserved indefinitely for forward migration
// to the future Teams design - do NOT drop them.
//
// When the new design lands, this router file is the replacement point:
// drop in the new handlers and remove the catch-all below.
router.all('*', (req, res) => {
res.status(503).json({
error: 'Teams temporarily unavailable',
message: 'The Teams feature is being redesigned to work within the new Workspaces system. It will return in a future release. Existing team data is preserved and will be migrated forward.',
reason: 'feature_redesign_in_progress',
});
});
module.exports = router;