- {SHORT_NAME}
+
+ {organizationInitials(org.name)}
+
{org.name}
{currentOrganizationId === org.id && (
diff --git a/src/lib/api/coaching-sessions.ts b/src/lib/api/coaching-sessions.ts
index 1aa8fa33..932b9f6d 100644
--- a/src/lib/api/coaching-sessions.ts
+++ b/src/lib/api/coaching-sessions.ts
@@ -202,6 +202,7 @@ export const CoachingSessionApi = {
* @param sortBy Optional field to sort by
* @param sortOrder Optional sort order
* @param relationshipId Optional coaching relationship ID to filter sessions
+ * @param organizationId Optional organization to scope sessions to
* @returns Promise resolving to array of EnrichedCoachingSession objects
*/
listForUser: async (
@@ -212,7 +213,8 @@ export const CoachingSessionApi = {
sortBy?: CoachingSessionSortField,
sortOrder?: ApiSortOrder,
relationshipId?: Id,
- tz?: string
+ tz?: string,
+ organizationId?: Id
): Promise
=> {
const params: Record = {
from_date: fromDate.toISODate() || '',
@@ -223,6 +225,10 @@ export const CoachingSessionApi = {
params.coaching_relationship_id = relationshipId;
}
+ if (organizationId) {
+ params.organization_id = organizationId;
+ }
+
if (include && include.length > 0) {
params.include = include.join(',');
}
@@ -245,7 +251,8 @@ export const CoachingSessionApi = {
fromDate: DateTime,
toDate: DateTime,
tz: string,
- relationshipId?: Id
+ relationshipId?: Id,
+ organizationId?: Id
): Promise => {
const fromIso = fromDate.toISODate();
const toIso = toDate.toISODate();
@@ -263,6 +270,9 @@ export const CoachingSessionApi = {
if (relationshipId) {
params.coaching_relationship_id = relationshipId;
}
+ if (organizationId) {
+ params.organization_id = organizationId;
+ }
const url = `${USERS_BASEURL}/${userId}/coaching_sessions/counts`;
const response = await EntityApi.getFn<{ counts: CoachingSessionCountByMonth[] }>(
@@ -395,6 +405,9 @@ export const useCoachingSessionMutation = () => {
* @param sortBy Optional field to sort by
* @param sortOrder Optional sort order
* @param relationshipId Optional coaching relationship ID to filter sessions
+ * @param organizationId Optional organization to scope sessions to. Part of the
+ * SWR key, so switching organizations refetches instead of serving the
+ * previous organization's cached list.
* @returns Object containing enriched sessions, loading state, error, and refresh function
*/
export const useEnrichedCoachingSessionsForUser = (
@@ -405,7 +418,8 @@ export const useEnrichedCoachingSessionsForUser = (
sortBy?: CoachingSessionSortField,
sortOrder?: ApiSortOrder,
relationshipId?: Id,
- tz?: string
+ tz?: string,
+ organizationId?: Id
) => {
// Only create params when userId is valid - null params skips the SWR fetch
const params = userId
@@ -415,6 +429,7 @@ export const useEnrichedCoachingSessionsForUser = (
to_date: toDate.toISODate(),
...(include && include.length > 0 && { include: include.join(',') }),
...(relationshipId && { coaching_relationship_id: relationshipId }),
+ ...(organizationId && { organization_id: organizationId }),
...(sortBy && { sort_by: sortBy }),
...(sortOrder && { sort_order: sortOrder }),
...(tz && { tz }),
@@ -433,7 +448,8 @@ export const useEnrichedCoachingSessionsForUser = (
sortBy,
sortOrder,
relationshipId,
- tz
+ tz,
+ organizationId
)
: Promise.resolve([]);
@@ -462,6 +478,8 @@ export const useEnrichedCoachingSessionsForUser = (
* @param toDate End date for the count window
* @param tz IANA timezone for local-calendar month aggregation on the BE
* @param relationshipId Optional relationship to narrow counts to one coachee
+ * @param organizationId Optional organization to scope counts to. Part of the
+ * SWR key so an organization switch refetches rather than reusing the cache.
* @returns counts, loading/error state, and a refresh fn. On error or 404,
* counts is an empty array — caller falls back to "no badge" rendering.
*/
@@ -470,7 +488,8 @@ export const useEnrichedCoachingSessionsForUserCounts = (
fromDate: DateTime,
toDate: DateTime,
tz: string,
- relationshipId?: Id
+ relationshipId?: Id,
+ organizationId?: Id
) => {
const params = userId
? {
@@ -480,6 +499,7 @@ export const useEnrichedCoachingSessionsForUserCounts = (
group_by: "month",
tz,
...(relationshipId && { coaching_relationship_id: relationshipId }),
+ ...(organizationId && { organization_id: organizationId }),
}
: null;
@@ -494,7 +514,8 @@ export const useEnrichedCoachingSessionsForUserCounts = (
fromDate,
toDate,
tz,
- relationshipId
+ relationshipId,
+ organizationId
)
: Promise.resolve([]);
diff --git a/src/lib/api/organization-errors.ts b/src/lib/api/organization-errors.ts
index 7fa0cc3c..56467711 100644
--- a/src/lib/api/organization-errors.ts
+++ b/src/lib/api/organization-errors.ts
@@ -38,6 +38,43 @@ export const organizationArchivedMessage = (error: unknown): string | null =>
"This organization is archived and can't accept new changes."
);
+/// Shown both by the lookup pre-check and by the server's 409, so the two
+/// paths cannot drift apart.
+export const USER_ALREADY_IN_ORGANIZATION_MESSAGE =
+ "This user is already a member of this organization.";
+
+export const userAlreadyInOrganizationMessage = (
+ error: unknown
+): string | null =>
+ orgErrorMessage(
+ error,
+ "user_already_in_organization",
+ USER_ALREADY_IN_ORGANIZATION_MESSAGE
+ );
+
+export const lastOrganizationAdminMessage = (error: unknown): string | null =>
+ orgErrorMessage(
+ error,
+ "last_organization_admin",
+ "This user is the only admin of this organization. Assign another admin before removing them."
+ );
+
+export const userHasCoachingHistoryMessage = (error: unknown): string | null =>
+ orgErrorMessage(
+ error,
+ "user_has_coaching_history",
+ "This member still has coaching sessions in this organization. Remove or reassign those sessions before removing them."
+ );
+
+export const userBelongsToMultipleOrganizationsMessage = (
+ error: unknown
+): string | null =>
+ orgErrorMessage(
+ error,
+ "user_belongs_to_multiple_organizations",
+ "This user belongs to other organizations. Remove them from this organization instead of deleting their account."
+ );
+
/** Longest organization name the backend accepts (characters, trimmed). */
export const ORGANIZATION_NAME_MAX_LENGTH = 255;
diff --git a/src/lib/api/organizations/users.ts b/src/lib/api/organizations/users.ts
index 05d645ea..7c50b5f8 100644
--- a/src/lib/api/organizations/users.ts
+++ b/src/lib/api/organizations/users.ts
@@ -1,13 +1,17 @@
// Interacts with the organizations/{organizationId}/users endpoints
+import { useSWRConfig } from "swr";
import { Id } from "@/types/general";
import { EntityApi } from "../entity-api";
-import { User, NewUser } from "@/types/user";
+import { User, NewUser, Role } from "@/types/user";
import { ORGANIZATIONS_BASEURL } from "../organizations";
const ORGANIZATIONS_USERS_BASEURL = (organizationId: Id) =>
`${ORGANIZATIONS_BASEURL}/${organizationId}/users`;
+/// `coach_id` is omitted rather than null when no coach is chosen.
+type AttachRoleBody = { role: Role; coach_id?: Id };
+
/**
* API client for user-related operations in the scope of organizations.
*/
@@ -78,6 +82,34 @@ export const UserApi = {
{}
);
},
+
+ /**
+ * Grants an existing user membership of this organization with the given role.
+ * The account itself is shared, not copied.
+ */
+ attachExisting: async (
+ organizationId: Id,
+ userId: Id,
+ role: Role,
+ coachId?: Id
+ ): Promise =>
+ EntityApi.createFn(
+ `${ORGANIZATIONS_USERS_BASEURL(organizationId)}/${userId}/role`,
+ { role, ...(coachId ? { coach_id: coachId } : {}) }
+ ),
+
+ /**
+ * Removes a user's membership of this organization only. Their account and
+ * any other organizations are left untouched.
+ */
+ removeFromOrganization: async (
+ organizationId: Id,
+ userId: Id
+ ): Promise => {
+ await EntityApi.deleteFn(
+ `${ORGANIZATIONS_USERS_BASEURL(organizationId)}/${userId}/role`
+ );
+ },
};
/**
@@ -101,10 +133,12 @@ export const useUserList = (organizationId: Id) => {
/**
* Hook for user mutations.
- * Provides methods to create, update, and delete users.
+ * Provides methods to create, update, and delete users, plus the membership
+ * actions (attach an existing user, remove one) that sit outside standard CRUD.
*/
export const useUserMutation = (organizationId: Id) => {
- return EntityApi.useEntityMutation(
+ const { mutate } = useSWRConfig();
+ const mutation = EntityApi.useEntityMutation(
ORGANIZATIONS_USERS_BASEURL(organizationId),
{
create: UserApi.create,
@@ -114,4 +148,24 @@ export const useUserMutation = (organizationId: Id) => {
deleteNested: UserApi.deleteNested,
}
);
+
+ const invalidate = (id: Id) =>
+ EntityApi.invalidateEntityCache(mutate, ORGANIZATIONS_USERS_BASEURL(id));
+
+ return {
+ ...mutation,
+ attachExisting: async (
+ orgId: Id,
+ userId: Id,
+ role: Role,
+ coachId?: Id
+ ) => {
+ await UserApi.attachExisting(orgId, userId, role, coachId);
+ invalidate(orgId);
+ },
+ removeFromOrganization: async (orgId: Id, userId: Id) => {
+ await UserApi.removeFromOrganization(orgId, userId);
+ invalidate(orgId);
+ },
+ };
};
diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts
index 5d166aef..c4563153 100644
--- a/src/lib/api/users.ts
+++ b/src/lib/api/users.ts
@@ -3,7 +3,14 @@
import { siteConfig } from "@/site.config";
import { Id } from "@/types/general";
import { EntityApi } from "./entity-api";
-import { User, NewUserPassword, defaultUser } from "@/types/user";
+import {
+ User,
+ NewUserPassword,
+ UserLookupResult,
+ defaultUser,
+} from "@/types/user";
+import { buildQueryString } from "./query-params";
+import { type Option, Some, None } from "@/types/option";
export const USERS_BASEURL: string = `${siteConfig.env.backendServiceURL}/users`;
@@ -12,10 +19,19 @@ export const USERS_BASEURL: string = `${siteConfig.env.backendServiceURL}/users`
*/
export const UserApi = {
/**
- * Fetches a list of users.
+ * Looks up a single user by exact (case-insensitive) email address.
+ *
+ * @param email The email address to look for
+ * @returns The matching user, or null when there is no match or the caller
+ * may not see them. The backend makes those two cases indistinguishable so
+ * the endpoint can't be used to enumerate accounts.
*/
- list: async (): Promise =>
- EntityApi.listFn(USERS_BASEURL, {}),
+ lookupByEmail: async (email: string): Promise