Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export const refreshSession = async (req: Request, res: Response) => {
userId: user.id,
infraId: session.infraId,
mode: session.mode,
organizationId: session.organizationId,
refreshTokenHash: newRefreshTokenHash,
refreshTokenLookup: newRefreshTokenLookup,
userAgent: session.userAgent,
Expand All @@ -354,7 +355,7 @@ export const refreshSession = async (req: Request, res: Response) => {
session.replacedBySessionId = newSession.id;
await session.save();

const token = await signAccessToken(newSession.id, user.id, user.roles);
const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId);

if (token && newRefreshToken) {
logger.info(
Expand All @@ -375,6 +376,7 @@ export const refreshSession = async (req: Request, res: Response) => {
refreshToken: newRefreshToken,
sub: user.id,
sessionId: newSession.id,
organizationId: session.organizationId,
roles: user.roles,
email: user.email,
phone: user.phone,
Expand Down
281 changes: 281 additions & 0 deletions src/controllers/organizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { Request, Response } from 'express';

import { getSystemConfig } from '../config/getSystemConfig.js';
import { setAuthCookies } from '../lib/cookie.js';
import { signAccessToken } from '../lib/token.js';
import { OrganizationMembership } from '../models/organizationMemberships.js';
import { Organization } from '../models/organizations.js';
import { Session } from '../models/sessions.js';
import { User } from '../models/users.js';
import {
countOwners,
createOrganizationForUser,
findMembership,
listAllOrganizations,
listOrganizationMembers,
listOrganizationsForUser,
normalizeMembershipValues,
normalizeOrganizationRoles,
normalizeOrganizationSlug,
requireOrganizationAccess,
requireOrganizationManager,
serializeMembership,
serializeOrganization,
} from '../services/organizationService.js';
import { AuthenticatedRequest } from '../types/types.js';
import { parseDurationToSeconds } from '../utils/utils.js';

function authUser(req: Request) {
return (req as AuthenticatedRequest).user;
}

function currentOrganizationId(req: Request) {
return (req as AuthenticatedRequest).organizationId ?? null;
}

function currentSessionId(req: Request) {
return (req as AuthenticatedRequest).sessionId;
}

export async function listOrganizations(req: Request, res: Response) {
const user = authUser(req);
const organizations = await listOrganizationsForUser(user.id);

return res.json({
organizations,
activeOrganizationId: currentOrganizationId(req),
});
}

export async function listAdminOrganizations(req: Request, res: Response) {
const organizations = await listAllOrganizations();
return res.json({ organizations, total: organizations.length });
}

export async function createOrganization(req: Request, res: Response) {
const user = authUser(req);
const { name, slug, metadata } = req.body;
const { organization, membership } = await createOrganizationForUser({
name,
slug,
user,
metadata,
});

return res.status(201).json({
organization: serializeOrganization(organization, membership),
});
}

export async function getOrganization(req: Request, res: Response) {
const user = authUser(req);
const { organizationId } = req.params;
const { organization, membership } = await requireOrganizationAccess(user, organizationId);

if (!organization) {
return res.status(404).json({ error: 'Organization not found' });
}

return res.json({
organization: serializeOrganization(organization, membership),
});
}

export async function updateOrganization(req: Request, res: Response) {
const user = authUser(req);
const { organizationId } = req.params;
const { organization, membership } = await requireOrganizationManager(user, organizationId);

if (!organization) {
return res.status(404).json({ error: 'Organization not found' });
}

const updates: Partial<Pick<Organization, 'name' | 'slug' | 'metadata'>> = {};

if (req.body.name !== undefined) {
updates.name = req.body.name;
}

if (req.body.slug !== undefined) {
updates.slug = normalizeOrganizationSlug(req.body.slug);
}

if (req.body.metadata !== undefined) {
updates.metadata = req.body.metadata;
}

await organization.update(updates);

return res.json({
organization: serializeOrganization(organization, membership),
});
}

export async function switchOrganization(req: Request, res: Response) {
const user = authUser(req);
const { organizationId } = req.params;
const sessionId = currentSessionId(req);
const { organization, membership } = await requireOrganizationAccess(user, organizationId);

if (!organization || !membership) {
return res.status(404).json({ error: 'Organization not found' });
}

if (!sessionId) {
return res.status(400).json({ error: 'Session context required' });
}

const session = await Session.findOne({
where: {
id: sessionId,
userId: user.id,
},
});

if (!session) {
return res.status(404).json({ error: 'Session not found' });
}

await session.update({ organizationId: organization.id });
const token = await signAccessToken(session.id, user.id, user.roles, organization.id);

if (process.env.AUTH_MODE === 'web') {
await setAuthCookies(res, { accessToken: token });
return res.json({
message: 'Success',
organization: serializeOrganization(organization, membership),
});
}

const { access_token_ttl } = await getSystemConfig();
return res.json({
message: 'Success',
token,
sub: user.id,
sessionId: session.id,
organizationId: organization.id,
organization: serializeOrganization(organization, membership),
ttl: parseDurationToSeconds(access_token_ttl || '15m'),
});
}

export async function listMembers(req: Request, res: Response) {
const user = authUser(req);
const { organizationId } = req.params;
const { organization } = await requireOrganizationAccess(user, organizationId);

if (!organization) {
return res.status(404).json({ error: 'Organization not found' });
}

const members = await listOrganizationMembers(organizationId);
return res.json({ members, total: members.length });
}

export async function addMember(req: Request, res: Response) {
const user = authUser(req);
const { organizationId } = req.params;
const { organization } = await requireOrganizationManager(user, organizationId);

if (!organization) {
return res.status(404).json({ error: 'Organization not found' });
}

const memberUser = req.body.userId
? await User.findByPk(req.body.userId)
: await User.findOne({ where: { email: req.body.email.toLowerCase() } });

if (!memberUser) {
return res.status(404).json({ error: 'User not found' });
}

const existing = await findMembership(memberUser.id, organizationId);

if (existing) {
return res.status(409).json({ error: 'User is already an organization member' });
}

const membership = await OrganizationMembership.create({
organizationId,
userId: memberUser.id,
roles: normalizeOrganizationRoles(req.body.roles),
scopes: normalizeMembershipValues(req.body.scopes),
});

return res.status(201).json({
membership: serializeMembership(membership, memberUser),
});
}

export async function updateMember(req: Request, res: Response) {
const user = authUser(req);
const { organizationId, userId } = req.params;
const { organization } = await requireOrganizationManager(user, organizationId);

if (!organization) {
return res.status(404).json({ error: 'Organization not found' });
}

const membership = await findMembership(userId, organizationId);

if (!membership) {
return res.status(404).json({ error: 'Membership not found' });
}

const nextRoles =
req.body.roles === undefined ? membership.roles : normalizeOrganizationRoles(req.body.roles);

if (membership.roles?.includes('owner') && !nextRoles.includes('owner')) {
const ownerCount = await countOwners(organizationId);
if (ownerCount <= 1) {
return res.status(400).json({ error: 'Organization must keep at least one owner' });
}
}

await membership.update({
roles: nextRoles,
scopes:
req.body.scopes === undefined
? membership.scopes
: normalizeMembershipValues(req.body.scopes),
});

const memberUser = await User.findByPk(userId);

return res.json({
membership: serializeMembership(membership, memberUser ?? undefined),
});
}

export async function removeMember(req: Request, res: Response) {
const user = authUser(req);
const { organizationId, userId } = req.params;
const { organization } = await requireOrganizationManager(user, organizationId);

if (!organization) {
return res.status(404).json({ error: 'Organization not found' });
}

const membership = await findMembership(userId, organizationId);

if (!membership) {
return res.status(404).json({ error: 'Membership not found' });
}

if (membership.roles?.includes('owner')) {
const ownerCount = await countOwners(organizationId);
if (ownerCount <= 1) {
return res.status(400).json({ error: 'Organization must keep at least one owner' });
}
}

await membership.destroy();

return res.json({ message: 'Success' });
}
43 changes: 26 additions & 17 deletions src/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AuthEvent } from '../models/authEvents.js';
import { Credential } from '../models/credentials.js';
import { User } from '../models/users.js';
import { AuthEventService } from '../services/authEventService.js';
import { listOrganizationsForUser } from '../services/organizationService.js';
import { AuthenticatedRequest } from '../types/types.js';
import getLogger from '../utils/logger.js';

Expand All @@ -23,23 +24,27 @@ export const getUser = async (req: Request, res: Response) => {

try {
if (authUser) {
const credentials = await Credential.findAll({
where: { userId: authUser.id },
attributes: [
'id',
'transports',
'deviceType',
'backedup',
'counter',
'prfCapable',
'friendlyName',
'lastUsedAt',
'platform',
'browser',
'deviceInfo',
'createdAt',
],
});
const [credentials, organizations] = await Promise.all([
Credential.findAll({
where: { userId: authUser.id },
attributes: [
'id',
'transports',
'deviceType',
'backedup',
'counter',
'prfCapable',
'friendlyName',
'lastUsedAt',
'platform',
'browser',
'deviceInfo',
'createdAt',
],
}),
listOrganizationsForUser(authUser.id),
]);
const activeOrganizationId = authReq.organizationId ?? null;

return res.json({
user: {
Expand All @@ -48,8 +53,12 @@ export const getUser = async (req: Request, res: Response) => {
phone: authUser.phone,
roles: authUser.roles,
lastLogin: authUser.lastLogin,
activeOrganizationId,
},
credentials,
organizations,
activeOrganization:
organizations.find((organization) => organization.id === activeOrganizationId) ?? null,
});
} else {
return res.status(404).json({ message: 'User not found' });
Expand Down
Loading
Loading