add idp auto provision override on user

This commit is contained in:
miloschwartz
2025-09-05 16:14:01 -07:00
parent 90456339ca
commit b0bd9279fc
24 changed files with 744 additions and 317 deletions

View File

@@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
"userSaved": "User saved",
"userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls",
"roles": "Roles",
@@ -911,6 +913,8 @@
"idpConnectingToFinished": "Connected",
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invalid Invite",
"inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user",
@@ -982,6 +986,8 @@
"licenseTierProfessionalRequired": "Professional Edition Required",
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
"actionGetOrg": "Get Organization",
"updateOrgUser": "Update Org User",
"createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization",
"actionUpdateUser": "Update User",
"actionGetUser": "Get User",
@@ -1496,5 +1502,7 @@
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
"willbestoredas": "Will be stored as:",
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider"
}

BIN
public/idp/azure.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
public/idp/google.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -100,7 +100,8 @@ export enum ActionsEnum {
getApiKey = "getApiKey",
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain"
restartOrgDomain = "restartOrgDomain",
updateOrgUser = "updateOrgUser"
}
export async function checkUserActionPermission(

View File

@@ -213,7 +213,8 @@ export const userOrgs = pgTable("userOrgs", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId),
isOwner: boolean("isOwner").notNull().default(false)
isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false)
});
export const emailVerificationCodes = pgTable("emailVerificationCodes", {

View File

@@ -107,7 +107,7 @@ export const resources = sqliteTable("resources", {
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade"
}),
})
});
export const targets = sqliteTable("targets", {
@@ -143,8 +143,11 @@ export const exitNodes = sqliteTable("exitNodes", {
type: text("type").default("gerbil") // gerbil, remoteExitNode
});
export const siteResources = sqliteTable("siteResources", { // this is for the clients
siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }),
export const siteResources = sqliteTable("siteResources", {
// this is for the clients
siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true
}),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
@@ -156,7 +159,7 @@ export const siteResources = sqliteTable("siteResources", { // this is for the c
proxyPort: integer("proxyPort").notNull(),
destinationPort: integer("destinationPort").notNull(),
destinationIp: text("destinationIp").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
export const users = sqliteTable("user", {
@@ -260,7 +263,9 @@ export const clientSites = sqliteTable("clientSites", {
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false),
isRelayed: integer("isRelayed", { mode: "boolean" })
.notNull()
.default(false),
endpoint: text("endpoint")
});
@@ -318,7 +323,10 @@ export const userOrgs = sqliteTable("userOrgs", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false)
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
}).default(false)
});
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {

View File

@@ -582,6 +582,14 @@ authenticated.put(
user.createOrgUser
);
authenticated.post(
"/org/:orgId/user/:userId",
verifyOrgAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.updateOrgUser),
user.updateOrgUser
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.post(

View File

@@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, idpOidcConfig } from "@server/db";
import { domains, idp, orgDomains, users, idpOrg } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -33,10 +33,13 @@ async function query(limit: number, offset: number) {
idpId: idp.idpId,
name: idp.name,
type: idp.type,
orgCount: sql<number>`count(${idpOrg.orgId})`
variant: idpOidcConfig.variant,
orgCount: sql<number>`count(${idpOrg.orgId})`,
autoProvision: idp.autoProvision
})
.from(idp)
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.groupBy(idp.idpId)
.limit(limit)
.offset(offset);
@@ -44,12 +47,7 @@ async function query(limit: number, offset: number) {
}
export type ListIdpsResponse = {
idps: Array<{
idpId: number;
name: string;
type: string;
orgCount: number;
}>;
idps: Awaited<ReturnType<typeof query>>;
pagination: {
total: number;
limit: number;

View File

@@ -354,8 +354,13 @@ export async function validateOidcCallback(
.from(userOrgs)
.where(eq(userOrgs.userId, userId!));
// Delete orgs that are no longer valid
const orgsToDelete = currentUserOrgs.filter(
// Filter to only auto-provisioned orgs for CRUD operations
const autoProvisionedOrgs = currentUserOrgs.filter(
(org) => org.autoProvisioned === true
);
// Delete auto-provisioned orgs that are no longer valid
const orgsToDelete = autoProvisionedOrgs.filter(
(currentOrg) =>
!userOrgInfo.some(
(newOrg) => newOrg.orgId === currentOrg.orgId
@@ -374,8 +379,8 @@ export async function validateOidcCallback(
);
}
// Update roles for existing orgs where the role has changed
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
// Update roles for existing auto-provisioned orgs where the role has changed
const orgsToUpdate = autoProvisionedOrgs.filter((currentOrg) => {
const newOrg = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
@@ -401,7 +406,7 @@ export async function validateOidcCallback(
}
}
// Add new orgs that don't exist yet
// Add new orgs that don't exist yet (these will be auto-provisioned)
const orgsToAdd = userOrgInfo.filter(
(newOrg) =>
!currentUserOrgs.some(
@@ -415,12 +420,14 @@ export async function validateOidcCallback(
userId: userId!,
orgId: org.orgId,
roleId: org.roleId,
autoProvisioned: true,
dateCreated: new Date().toISOString()
}))
);
}
// Loop through all the orgs and get the total number of users from the userOrgs table
// Use all current user orgs (both auto-provisioned and manually added) for counting
for (const org of currentUserOrgs) {
const userCount = await trx
.select()

View File

@@ -24,7 +24,8 @@ import {
verifyApiKeyIsRoot,
verifyApiKeyClientAccess,
verifyClientsEnabled,
verifyApiKeySiteResourceAccess
verifyApiKeySiteResourceAccess,
verifyOrgAccess
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -469,6 +470,21 @@ authenticated.get(
user.listUsers
);
authenticated.put(
"/org/:orgId/user",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
user.createOrgUser
);
authenticated.post(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
user.updateOrgUser
);
authenticated.delete(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,

View File

@@ -84,7 +84,14 @@ export async function createOrgUser(
}
const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleId } = parsedBody.data;
const {
username,
email,
name,
type,
idpId,
roleId
} = parsedBody.data;
const [role] = await db
.select()
@@ -173,7 +180,8 @@ export async function createOrgUser(
.values({
orgId,
userId: existingUser.userId,
roleId: role.roleId
roleId: role.roleId,
autoProvisioned: false
})
.returning();
} else {
@@ -189,7 +197,7 @@ export async function createOrgUser(
type: "oidc",
idpId,
dateCreated: new Date().toISOString(),
emailVerified: true
emailVerified: true,
})
.returning();
@@ -198,7 +206,8 @@ export async function createOrgUser(
.values({
orgId,
userId: newUser.userId,
roleId: role.roleId
roleId: role.roleId,
autoProvisioned: false
})
.returning();
}
@@ -209,7 +218,6 @@ export async function createOrgUser(
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
});
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) {
isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId,
idpName: idp.name,
idpType: idp.type,
idpVariant: idpOidcConfig.variant,
idpAutoProvision: idp.autoProvision
})
.from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
return user;

View File

@@ -13,3 +13,4 @@ export * from "./removeInvitation";
export * from "./createOrgUser";
export * from "./adminUpdateUser2FA";
export * from "./adminGetUser";
export * from "./updateOrgUser";

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, idpOidcConfig } from "@server/db";
import { idp, roles, userOrgs, users } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -50,12 +50,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId,
idpType: idp.type,
idpVariant: idpOidcConfig.variant,
twoFactorEnabled: users.twoFactorEnabled,
})
.from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);

View File

@@ -0,0 +1,112 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
userId: z.string(),
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
autoProvisioned: z.boolean().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/user/{userId}",
description: "Update a user in an org.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateOrgUser(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId, orgId } = parsedParams.data;
const [existingUser] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!existingUser) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in this organization"
)
);
}
const updateData = parsedBody.data;
const [updatedUser] = await db
.update(userOrgs)
.set({
...updateData
})
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.returning();
return response(res, {
data: updatedUser,
success: true,
error: false,
message: "Org user updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -16,6 +16,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Checkbox } from "@app/components/ui/checkbox";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse } from "@server/routers/user";
@@ -41,6 +42,8 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext();
@@ -56,14 +59,16 @@ export default function AccessControlsPage() {
const formSchema = z.object({
username: z.string(),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }),
autoProvisioned: z.boolean()
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: user.username!,
roleId: user.roleId?.toString()
roleId: user.roleId?.toString(),
autoProvisioned: user.autoProvisioned || false
}
});
@@ -91,16 +96,29 @@ export default function AccessControlsPage() {
fetchRoles();
form.setValue("roleId", user.roleId.toString());
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<
AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`)
.catch((e) => {
try {
// Execute both API calls simultaneously
const [roleRes, userRes] = await Promise.all([
api.post<AxiosResponse<InviteUserResponse>>(`/role/${values.roleId}/add/${user.userId}`),
api.post(`/org/${orgId}/user/${user.userId}`, {
autoProvisioned: values.autoProvisioned
})
]);
if (roleRes.status === 200 && userRes.status === 200) {
toast({
variant: "default",
title: t('userSaved'),
description: t('userSavedDescription')
});
}
} catch (e) {
toast({
variant: "destructive",
title: t('accessRoleErrorAdd'),
@@ -109,14 +127,6 @@ export default function AccessControlsPage() {
t('accessRoleErrorAddDescription')
)
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: t('userSaved'),
description: t('userSavedDescription')
});
}
setLoading(false);
@@ -140,6 +150,20 @@ export default function AccessControlsPage() {
className="space-y-4"
id="access-controls-form"
>
{/* IDP Type Display */}
{user.type !== UserType.Internal && user.idpType && (
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm font-medium text-muted-foreground">
{t("idp")}:
</span>
<IdpTypeBadge
type={user.idpType}
variant={user.idpVariant || undefined}
name={user.idpName || undefined}
/>
</div>
)}
<FormField
control={form.control}
name="roleId"
@@ -147,7 +171,13 @@ export default function AccessControlsPage() {
<FormItem>
<FormLabel>{t('role')}</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={(value) => {
field.onChange(value);
// If auto provision is enabled, set it to false when role changes
if (user.idpAutoProvision) {
form.setValue("autoProvisioned", false);
}
}}
value={field.value}
>
<FormControl>
@@ -170,6 +200,31 @@ export default function AccessControlsPage() {
</FormItem>
)}
/>
{user.idpAutoProvision && (
<FormField
control={form.control}
name="autoProvisioned"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t('autoProvisioned')}
</FormLabel>
<p className="text-sm text-muted-foreground">
{t('autoProvisionedDescription')}
</p>
</div>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -46,6 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import Image from "next/image";
type UserType = "internal" | "oidc";
@@ -53,6 +54,17 @@ interface IdpOption {
idpId: number;
name: string;
type: string;
variant: string | null;
}
interface UserOption {
id: string;
title: string;
description: string;
disabled: boolean;
icon?: React.ReactNode;
idpId?: number;
variant?: string | null;
}
export default function Page() {
@@ -62,14 +74,14 @@ export default function Page() {
const api = createApiClient({ env });
const t = useTranslations();
const [userType, setUserType] = useState<UserType | null>("internal");
const [selectedOption, setSelectedOption] = useState<string | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [dataLoaded, setDataLoaded] = useState(false);
const internalFormSchema = z.object({
@@ -80,7 +92,13 @@ export default function Page() {
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
});
const externalFormSchema = z.object({
const googleAzureFormSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
});
const genericOidcFormSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
email: z
.string()
@@ -96,11 +114,44 @@ export default function Page() {
switch (type.toLowerCase()) {
case "oidc":
return t("idpGenericOidc");
case "google":
return t("idpGoogleDescription");
case "azure":
return t("idpAzureDescription");
default:
return type;
}
};
const getIdpIcon = (variant: string | null) => {
if (!variant) return null;
switch (variant.toLowerCase()) {
case "google":
return (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
);
case "azure":
return (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
);
default:
return null;
}
};
const validFor = [
{ hours: 24, name: t("day", { count: 1 }) },
{ hours: 48, name: t("day", { count: 2 }) },
@@ -120,8 +171,17 @@ export default function Page() {
}
});
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
resolver: zodResolver(externalFormSchema),
const googleAzureForm = useForm<z.infer<typeof googleAzureFormSchema>>({
resolver: zodResolver(googleAzureFormSchema),
defaultValues: {
email: "",
name: "",
roleId: ""
}
});
const genericOidcForm = useForm<z.infer<typeof genericOidcFormSchema>>({
resolver: zodResolver(genericOidcFormSchema),
defaultValues: {
username: "",
email: "",
@@ -132,33 +192,19 @@ export default function Page() {
});
useEffect(() => {
if (userType === "internal") {
if (selectedOption === "internal") {
setSendEmail(env.email.emailEnabled);
internalForm.reset();
setInviteLink(null);
setExpiresInDays(1);
} else if (userType === "oidc") {
externalForm.reset();
} else if (selectedOption && selectedOption !== "internal") {
googleAzureForm.reset();
genericOidcForm.reset();
}
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
const [userTypes, setUserTypes] = useState<StrategyOption<string>[]>([
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription"),
disabled: false
},
{
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription"),
disabled: true
}
]);
}, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
useEffect(() => {
if (!userType) {
if (!selectedOption) {
return;
}
@@ -199,20 +245,6 @@ export default function Page() {
if (res?.status === 200) {
setIdps(res.data.data.idps);
if (res.data.data.idps.length) {
setUserTypes((prev) =>
prev.map((type) => {
if (type.id === "oidc") {
return {
...type,
disabled: false
};
}
return type;
})
);
}
}
}
@@ -226,6 +258,33 @@ export default function Page() {
fetchInitialData();
}, []);
// Build user options when IDPs are loaded
useEffect(() => {
const options: UserOption[] = [
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription"),
disabled: false
}
];
// Add IDP options
idps.forEach((idp) => {
options.push({
id: `idp-${idp.idpId}`,
title: idp.name,
description: formatIdpType(idp.variant || idp.type),
disabled: false,
icon: getIdpIcon(idp.variant),
idpId: idp.idpId,
variant: idp.variant
});
});
setUserOptions(options);
}, [idps, t]);
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
@@ -274,9 +333,52 @@ export default function Page() {
setLoading(false);
}
async function onSubmitExternal(
values: z.infer<typeof externalFormSchema>
async function onSubmitGoogleAzure(
values: z.infer<typeof googleAzureFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
if (!selectedUserOption?.idpId) return;
setLoading(true);
const res = await api
.put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure
email: values.email,
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId)
})
.catch((e) => {
toast({
variant: "destructive",
title: t("userErrorCreate"),
description: formatAxiosError(
e,
t("userErrorCreateDescription")
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: t("userCreated"),
description: t("userCreatedDescription")
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
async function onSubmitGenericOidc(
values: z.infer<typeof genericOidcFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
if (!selectedUserOption?.idpId) return;
setLoading(true);
const res = await api
@@ -285,7 +387,7 @@ export default function Page() {
email: values.email,
name: values.name,
type: "oidc",
idpId: parseInt(values.idpId),
idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId)
})
.catch((e) => {
@@ -330,7 +432,7 @@ export default function Page() {
<div>
<SettingsContainer>
{!inviteLink && build !== "saas" ? (
{!inviteLink && build !== "saas" && dataLoaded ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -342,15 +444,15 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={userTypes}
defaultValue={userType || undefined}
options={userOptions}
defaultValue={selectedOption || undefined}
onChange={(value) => {
setUserType(value as UserType);
setSelectedOption(value);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
} else {
googleAzureForm.reset();
genericOidcForm.reset();
}
}}
cols={2}
@@ -359,7 +461,7 @@ export default function Page() {
</SettingsSection>
) : null}
{userType === "internal" && dataLoaded && (
{selectedOption === "internal" && dataLoaded && (
<>
{!inviteLink ? (
<SettingsSection>
@@ -564,71 +666,7 @@ export default function Page() {
</>
)}
{userType !== "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpSelect")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
{t("idpNotConfigured")}
</p>
) : (
<Form {...externalForm}>
<FormField
control={externalForm.control}
name="idpId"
render={({ field }) => (
<FormItem>
<StrategySelect
options={idps.map(
(idp) => ({
id: idp.idpId.toString(),
title: idp.name,
description:
formatIdpType(
idp.type
)
})
)}
defaultValue={
field.value
}
onChange={(
value
) => {
field.onChange(
value
);
const idp =
idps.find(
(idp) =>
idp.idpId.toString() ===
value
);
setSelectedIdp(
idp || null
);
}}
cols={2}
/>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
</SettingsSectionBody>
</SettingsSection>
{idps.length > 0 && (
{selectedOption && selectedOption !== "internal" && dataLoaded && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -640,52 +678,26 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...externalForm}>
{/* Google/Azure Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
})() && (
<Form {...googleAzureForm}>
<form
onSubmit={externalForm.handleSubmit(
onSubmitExternal
onSubmit={googleAzureForm.handleSubmit(
onSubmitGoogleAzure
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
externalForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"username"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t(
"usernameUniq"
)}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
control={googleAzureForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"emailOptional"
)}
{t("email")}
</FormLabel>
<FormControl>
<Input
@@ -698,16 +710,12 @@ export default function Page() {
/>
<FormField
control={
externalForm.control
}
control={googleAzureForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"nameOptional"
)}
{t("nameOptional")}
</FormLabel>
<FormControl>
<Input
@@ -720,9 +728,7 @@ export default function Page() {
/>
<FormField
control={
externalForm.control
}
control={googleAzureForm.control}
name="roleId"
render={({ field }) => (
<FormItem>
@@ -730,36 +736,24 @@ export default function Page() {
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
placeholder={t("accessRoleSelect")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
{roles.map((role) => (
<SelectItem
key={
role.roleId
}
key={role.roleId}
value={role.roleId.toString()}
>
{
role.name
}
{role.name}
</SelectItem>
)
)}
))}
</SelectContent>
</Select>
<FormMessage />
@@ -768,16 +762,122 @@ export default function Page() {
/>
</form>
</Form>
)}
{/* Generic OIDC Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure";
})() && (
<Form {...genericOidcForm}>
<form
onSubmit={genericOidcForm.handleSubmit(
onSubmitGenericOidc
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={genericOidcForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t("usernameUniq")}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("emailOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{userType && dataLoaded && (
{selectedOption && dataLoaded && (
<Button
type={inviteLink ? "button" : "submit"}
form={inviteLink ? undefined : "create-user-form"}

View File

@@ -77,6 +77,7 @@ export default async function UsersPage(props: UsersPageProps) {
name: user.name,
email: user.email,
type: user.type,
idpVariant: user.idpVariant,
idpId: user.idpId,
idpName: user.idpName || t('idpNameInternal'),
status: t('userConfirmed'),

View File

@@ -42,7 +42,8 @@ export default async function Page(props: {
)();
const loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
const t = await getTranslations();

View File

@@ -20,12 +20,14 @@ import {
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "./IdpTypeBadge";
export type IdpRow = {
idpId: number;
name: string;
type: string;
orgCount: number;
variant?: string;
};
type Props = {
@@ -57,15 +59,6 @@ export default function IdpTable({ idps }: Props) {
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "oidc":
return "OAuth2/OIDC";
default:
return type;
}
};
const columns: ColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
@@ -116,9 +109,8 @@ export default function IdpTable({ idps }: Props) {
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
const variant = row.original.variant;
return <IdpTypeBadge type={type} variant={variant} />;
}
},
{

View File

@@ -0,0 +1,62 @@
"use client";
import { Badge } from "@app/components/ui/badge";
import Image from "next/image";
type IdpTypeBadgeProps = {
type: string;
variant?: string;
name?: string;
};
export default function IdpTypeBadge({
type,
variant,
name
}: IdpTypeBadgeProps) {
const effectiveType = variant || type;
const effectiveName = name || formatType(effectiveType);
function formatType(type: string) {
if (type === "google") return "Google";
if (type === "azure") return "Azure";
if (type === "oidc") return "OAuth2/OIDC";
return type.charAt(0).toUpperCase() + type.slice(1);
}
return (
<Badge
variant="secondary"
className="inline-flex items-center space-x-1 w-fit"
>
{effectiveType === "google" && (
<>
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
<span>{effectiveName}</span>
</>
)}
{effectiveType === "azure" && (
<>
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
<span>{effectiveName}</span>
</>
)}
{effectiveType === "oidc" && <span>{effectiveName}</span>}
{!["google", "azure", "oidc"].includes(effectiveType) && (
<span>{effectiveName}</span>
)}
</Badge>
);
}

View File

@@ -46,6 +46,7 @@ import { startAuthentication } from "@simplewebauthn/browser";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
};
type LoginFormProps = {
@@ -496,19 +497,41 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
</div>
{idps.map((idp) => (
{idps.map((idp) => {
const effectiveType = idp.variant || idp.name.toLowerCase();
return (
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
{idp.name}
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<span>{idp.name}</span>
</Button>
))}
);
})}
</>
)}
</>

View File

@@ -27,7 +27,9 @@ function getActionsCategories(root: boolean) {
[t('actionListInvitations')]: "listInvitations",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionListOrgDomains')]: "listOrgDomains"
[t('actionListOrgDomains')]: "listOrgDomains",
[t('updateOrgUser')]: "updateOrgUser",
[t('createOrgUser')]: "createOrgUser"
},
Site: {

View File

@@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "./IdpTypeBadge";
export type UserRow = {
id: string;
@@ -31,6 +32,7 @@ export type UserRow = {
idpId: number | null;
idpName: string;
type: string;
idpVariant: string | null;
status: string;
role: string;
isOwner: boolean;
@@ -81,6 +83,16 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<IdpTypeBadge
type={userRow.type}
name={userRow.idpName}
variant={userRow.idpVariant || undefined}
/>
);
}
},
{