mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-16 13:06:27 +00:00
add idp auto provision override on user
This commit is contained in:
@@ -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
BIN
public/idp/azure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
public/idp/google.png
Normal file
BIN
public/idp/google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -100,7 +100,8 @@ export enum ActionsEnum {
|
||||
getApiKey = "getApiKey",
|
||||
createOrgDomain = "createOrgDomain",
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain"
|
||||
restartOrgDomain = "restartOrgDomain",
|
||||
updateOrgUser = "updateOrgUser"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./updateOrgUser";
|
||||
|
||||
@@ -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);
|
||||
|
||||
112
server/routers/user/updateOrgUser.ts
Normal file
112
server/routers/user/updateOrgUser.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
62
src/components/IdpTypeBadge.tsx
Normal file
62
src/components/IdpTypeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user