mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-12 07:56:40 +00:00
successful log in loop poc
This commit is contained in:
@@ -24,7 +24,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
email: string | null;
|
||||
displayUsername: string | null;
|
||||
username: string;
|
||||
name: string | null;
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
type: string;
|
||||
status: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
@@ -82,7 +88,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
Manage User
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{userRow.email !== user?.email && (
|
||||
{userRow.username !==
|
||||
user?.username && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(
|
||||
@@ -108,7 +115,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
accessorKey: "displayUsername",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -117,14 +124,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
Username
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -133,7 +140,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Status
|
||||
Identity Provider
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -185,7 +192,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
>
|
||||
Manage
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
@@ -239,7 +249,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove{" "}
|
||||
<b>{selectedUser?.email}</b> from the organization?
|
||||
<b>
|
||||
{selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username}
|
||||
</b>{" "}
|
||||
from the organization?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -250,14 +265,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email address of the
|
||||
user below.
|
||||
To confirm, please type the name of the of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove User"
|
||||
onConfirm={removeUser}
|
||||
string={selectedUser?.email ?? ""}
|
||||
string={
|
||||
selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username ||
|
||||
""
|
||||
}
|
||||
title="Remove User from Organization"
|
||||
/>
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
const userRows: UserRow[] = users.map((user) => {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayUsername: user.email || user.name || user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
type: user.type,
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || "Internal",
|
||||
status: "Confirmed",
|
||||
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
||||
isOwner: user.isOwner || false
|
||||
|
||||
@@ -45,6 +45,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
@@ -175,7 +176,7 @@ export default function ResourceAuthenticationPage() {
|
||||
setAllUsers(
|
||||
usersResponse.data.data.users.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: user.email
|
||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -183,7 +184,7 @@ export default function ResourceAuthenticationPage() {
|
||||
"users",
|
||||
resourceUsersResponse.data.data.users.map((i) => ({
|
||||
id: i.userId.toString(),
|
||||
text: i.email
|
||||
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
type: string;
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
dateCreated: string;
|
||||
};
|
||||
|
||||
@@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) {
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => {
|
||||
@@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) {
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Identity Provider
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
@@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b>{selected?.email || selected?.id}</b> from
|
||||
the server?
|
||||
<b>
|
||||
{selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username}
|
||||
</b>{" "}
|
||||
from the server?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email of the user
|
||||
To confirm, please type the name of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={selected.email}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title="Delete User from Server"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,11 @@ export default async function UsersPage(props: PageProps) {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
username: row.username,
|
||||
type: row.type,
|
||||
idpId: row.idpId,
|
||||
idpName: row.idpName || "Internal",
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
};
|
||||
|
||||
@@ -4,56 +4,58 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
idpId: string;
|
||||
code: string | undefined;
|
||||
verifier: string | undefined;
|
||||
storedState: string | undefined;
|
||||
expectedState: string | undefined;
|
||||
stateCookie: string | undefined;
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.code || !props.verifier) {
|
||||
setError("Missing code or verifier");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.storedState) {
|
||||
setError("Missing stored state");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.storedState !== props.expectedState) {
|
||||
setError("Invalid state");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log("Validating OIDC token", {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||
>(
|
||||
`/auth/org/${props.orgId}/idp/${props.idpId}/oidc/validate-callback`,
|
||||
{
|
||||
code: props.code,
|
||||
codeVerifier: props.verifier
|
||||
}
|
||||
);
|
||||
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
|
||||
code: props.code,
|
||||
state: props.expectedState,
|
||||
storedState: props.stateCookie
|
||||
});
|
||||
|
||||
console.log("Validate OIDC token response", res.data);
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe
|
||||
} else {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, "Error validating OIDC token"));
|
||||
} finally {
|
||||
@@ -12,8 +12,7 @@ export default async function Page(props: {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const allCookies = await cookies();
|
||||
const stateCookie = allCookies.get("oidc_state")?.value;
|
||||
const verifier = allCookies.get("oidc_code_verifier")?.value;
|
||||
const stateCookie = allCookies.get("p_oidc_state")?.value;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,9 +20,8 @@ export default async function Page(props: {
|
||||
orgId={params.orgId}
|
||||
idpId={params.idpId}
|
||||
code={searchParams.code}
|
||||
storedState={stateCookie}
|
||||
expectedState={searchParams.state}
|
||||
verifier={verifier}
|
||||
stateCookie={stateCookie}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -36,7 +36,7 @@ export default async function Page(props: {
|
||||
return (
|
||||
<>
|
||||
<VerifyEmailForm
|
||||
email={user.email}
|
||||
email={user.email!}
|
||||
redirect={redirectUrl}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -35,7 +35,13 @@ export const orgNavItems: SidebarNavItem[] = [
|
||||
children: [
|
||||
{
|
||||
title: "Users",
|
||||
href: "/{orgId}/settings/access/users"
|
||||
href: "/{orgId}/settings/access/users",
|
||||
children: [
|
||||
{
|
||||
title: "Invitations",
|
||||
href: "/{orgId}/settings/access/invitations"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { LoginResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { AxiosResponse, AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
} from "./ui/input-otp";
|
||||
import Link from "next/link";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import Image from 'next/image'
|
||||
import Image from "next/image";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
|
||||
type LoginFormProps = {
|
||||
redirect?: string;
|
||||
@@ -130,60 +131,93 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
|
||||
`/auth/idp/${idpId}/oidc/generate-url`,
|
||||
{
|
||||
redirectUrl: redirect || "/" // this is the post auth redirect url
|
||||
}
|
||||
);
|
||||
|
||||
console.log(res);
|
||||
|
||||
if (!res) {
|
||||
setError("An error occurred while logging in");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data.data;
|
||||
window.location.href = data.redirectUrl;
|
||||
} catch (e) {
|
||||
console.error(formatAxiosError(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!mfaRequested && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
loginWithIdp(1);
|
||||
}}
|
||||
>
|
||||
OIDC Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
@@ -193,7 +227,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the code from your authenticator app or one of your single-use backup codes.
|
||||
Enter the code from your authenticator app or one of
|
||||
your single-use backup codes.
|
||||
</p>
|
||||
</div>
|
||||
<Form {...mfaForm}>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function ProfileIcon() {
|
||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||
|
||||
function getInitials() {
|
||||
return user.email.substring(0, 1).toUpperCase();
|
||||
return (user.email || user.name || user.username).substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function handleThemeChange(theme: "light" | "dark" | "system") {
|
||||
@@ -68,7 +68,7 @@ export default function ProfileIcon() {
|
||||
|
||||
<div className="flex items-center md:gap-4 grow min-w-0 gap-2 md:gap-0">
|
||||
<span className="truncate max-w-full font-medium min-w-0">
|
||||
{user.email}
|
||||
{user.email || user.name || user.username}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -92,7 +92,7 @@ export default function ProfileIcon() {
|
||||
Signed in as
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
{user.email || user.name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
{user.serverAdmin && (
|
||||
|
||||
Reference in New Issue
Block a user