Add identity-scoped OAuth token management

Let users create, list, and revoke manual bearer tokens from
/me/oauth-tokens, scoped to their identity rather than an
organization. Manual tokens store a null client_id and are
authorized with a self-manage IAM policy.

Wire Connect GraphQL on Identity (list, create, revoke), add
console UI with scoped create flow and credentials dialog, and
cover the flow in e2e tests. Fix list pagination ordering and
keep the Relay connection in sync after create.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-17 16:07:50 +02:00
parent e20f1de58a
commit 26c5002932
32 changed files with 2081 additions and 52 deletions

View File

@@ -18,6 +18,7 @@ import { useTranslate } from "@probo/i18n";
import {
Button,
IconArrowsClockwise,
IconChevronDown,
IconEnvelope,
IconKey,
IconLockOpen,
@@ -25,12 +26,13 @@ import {
IconUserCircle,
useToast,
} from "@probo/ui";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { ConsentPageMutation } from "#/__generated__/iam/ConsentPageMutation.graphql";
import type { ConsentPageQuery } from "#/__generated__/iam/ConsentPageQuery.graphql";
import { formatApiScopeLabel } from "#/pages/iam/oauthTokens/_components/scopeLabels";
export const consentPageQuery = graphql`
query ConsentPageQuery($consentId: ID!) {
@@ -74,7 +76,81 @@ function scopeIcon(name: string): React.ReactNode {
}
function scopeLabel(name: string): string {
return scopeLabels[name] ?? name;
return scopeLabels[name] ?? formatApiScopeLabel(name);
}
function isApiScope(scope: string): boolean {
return scope.startsWith("v1:");
}
function partitionScopes(scopes: readonly string[]) {
const oidcScopes: string[] = [];
const apiScopes: string[] = [];
for (const scope of scopes) {
if (isApiScope(scope)) {
apiScopes.push(scope);
} else {
oidcScopes.push(scope);
}
}
return { oidcScopes, apiScopes };
}
function ConsentScopeRow(props: {
scope: string;
translate: (label: string) => string;
nested?: boolean;
}) {
const label = scopeLabel(props.scope);
const translated = label !== props.scope ? props.translate(label) : label;
return (
<li
className={
props.nested
? "flex items-center gap-2.5 py-1.5 text-sm text-txt-secondary"
: "flex items-center gap-2.5 px-3 py-2.5 text-sm text-txt-secondary border border-border-mid rounded-lg"
}
>
{scopeIcon(props.scope)}
{translated}
</li>
);
}
function ConsentApiScopesAccordion(props: {
scopes: readonly string[];
translate: (label: string) => string;
summaryLabel: string;
}) {
if (props.scopes.length === 0) {
return null;
}
return (
<details className="group border border-border-mid rounded-lg">
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3 py-2.5 text-sm text-txt-secondary select-none [&::-webkit-details-marker]:hidden">
<IconKey size={18} className="shrink-0 text-txt-tertiary" />
<span className="min-w-0 flex-1 text-start">{props.summaryLabel}</span>
<IconChevronDown
size={16}
className="shrink-0 text-txt-tertiary transition-transform group-open:rotate-180"
/>
</summary>
<ul className="space-y-1 border-t border-border-mid px-3 py-2.5">
{props.scopes.map(scope => (
<ConsentScopeRow
key={scope}
scope={scope}
translate={props.translate}
nested
/>
))}
</ul>
</details>
);
}
export default function ConsentPage(props: {
@@ -92,6 +168,16 @@ export default function ConsentPage(props: {
const [approveConsent, isInFlight]
= useMutation<ConsentPageMutation>(approveConsentMutation);
const { oidcScopes, apiScopes } = useMemo(
() => partitionScopes(consent.scopes ?? []),
[consent.scopes],
);
const apiScopesSummary = useMemo(
() => `${__("API access")} (${apiScopes.length})`,
[__, apiScopes.length],
);
const handleAction = useCallback(
(approved: boolean) => {
if (!consent.id) return;
@@ -200,22 +286,25 @@ export default function ConsentPage(props: {
</p>
</div>
<ul className="space-y-2">
{consent.scopes.map((scope) => {
const label = scopeLabel(scope);
const translated = scopeLabels[scope] ? __(label) : label;
<div className="space-y-2">
{oidcScopes.length > 0 && (
<ul className="space-y-2">
{oidcScopes.map(scope => (
<ConsentScopeRow
key={scope}
scope={scope}
translate={__}
/>
))}
</ul>
)}
return (
<li
key={scope}
className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-txt-secondary border border-border-mid rounded-lg"
>
{scopeIcon(scope)}
{translated}
</li>
);
})}
</ul>
<ConsentApiScopesAccordion
scopes={apiScopes}
translate={__}
summaryLabel={apiScopesSummary}
/>
</div>
<div className="flex gap-3">
<Button

View File

@@ -32,6 +32,7 @@ import type { ViewerDropdownSignOutMutation } from "#/__generated__/iam/ViewerDr
export const fragment = graphql`
fragment ViewerDropdownFragment on Identity {
canListAPIKeys: permission(action: "iam:personal-api-key:list")
canListOAuth2AccessTokens: permission(action: "iam:oauth2-access-token:list")
email
fullName
}
@@ -51,7 +52,7 @@ export function ViewerDropdown(props: { fKey: ViewerDropdownFragment$key }) {
const { __ } = useTranslate();
const { toast } = useToast();
const { canListAPIKeys, email, fullName }
const { canListAPIKeys, canListOAuth2AccessTokens, email, fullName }
= useFragment<ViewerDropdownFragment$key>(fragment, fKey);
const [signOut] = useMutation<ViewerDropdownSignOutMutation>(signOutMutation);
@@ -90,6 +91,13 @@ export function ViewerDropdown(props: { fKey: ViewerDropdownFragment$key }) {
label={__("API Keys")}
/>
)}
{canListOAuth2AccessTokens && (
<UserDropdownItem
to="/me/oauth-tokens"
icon={IconKey}
label={__("OAuth tokens")}
/>
)}
<UserDropdownItem
to="mailto:support@probo.com"
icon={IconCircleQuestionmark}

View File

@@ -0,0 +1,301 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Card,
Checkbox,
Field,
Input,
Label,
Option,
PageHeader,
Select,
useDialogRef,
} from "@probo/ui";
import { useMemo, useState } from "react";
import { Controller } from "react-hook-form";
import { ConnectionHandler, useLazyLoadQuery } from "react-relay";
import { Link, useNavigate } from "react-router";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { NewOAuthTokenPageCreateMutation } from "#/__generated__/iam/NewOAuthTokenPageCreateMutation.graphql";
import type { NewOAuthTokenPageQuery } from "#/__generated__/iam/NewOAuthTokenPageQuery.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { OAuthTokenCredentialsDialog } from "./_components/OAuthTokenCredentialsDialog";
import { formatApiScopeLabel } from "./_components/scopeLabels";
const pageQuery = graphql`
query NewOAuthTokenPageQuery {
oauth2ScopesSupported
viewer @required(action: THROW) {
id
canCreateOAuth2AccessToken: permission(
action: "iam:oauth2-access-token:create"
)
}
}
`;
const createMutation = graphql`
mutation NewOAuthTokenPageCreateMutation(
$input: CreateOAuth2AccessTokenInput!
$connections: [ID!]!
) {
createOAuth2AccessToken(input: $input) {
token
oauth2AccessTokenEdge @prependEdge(connections: $connections) {
node {
id
...OAuthTokenRowFragment
}
}
}
}
`;
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresIn: z.enum(["1month", "3months", "6months", "1year"]),
scopes: z.array(z.string()).min(1, "Select at least one scope"),
});
type CreateFormData = z.infer<typeof createSchema>;
function computeExpiresAt(expiresIn: CreateFormData["expiresIn"]) {
const now = new Date();
const expiresAt = new Date(now);
switch (expiresIn) {
case "1month":
expiresAt.setMonth(now.getMonth() + 1);
break;
case "3months":
expiresAt.setMonth(now.getMonth() + 3);
break;
case "6months":
expiresAt.setMonth(now.getMonth() + 6);
break;
case "1year":
expiresAt.setFullYear(now.getFullYear() + 1);
break;
}
return expiresAt;
}
export function NewOAuthTokenPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const tokenDialogRef = useDialogRef();
const [token, setToken] = useState("");
usePageTitle(__("New OAuth token"));
const data = useLazyLoadQuery<NewOAuthTokenPageQuery>(pageQuery, {});
const viewer = data.viewer;
const supportedScopes = useMemo(
() => [...data.oauth2ScopesSupported].sort(),
[data.oauth2ScopesSupported],
);
const { formState, handleSubmit, register, control, watch, setValue }
= useFormWithSchema(createSchema, {
defaultValues: {
name: new Date().toISOString().split("T")[0],
expiresIn: "1year",
scopes: [] as string[],
},
});
const selectedScopes = watch("scopes");
const [create, isCreating] = useMutationWithToasts<NewOAuthTokenPageCreateMutation>(
createMutation,
{
successMessage: "OAuth token created successfully.",
errorMessage: "Failed to create OAuth token",
},
);
if (!viewer.canCreateOAuth2AccessToken) {
throw new Error("forbidden");
}
const toggleScope = (scope: string, checked: boolean) => {
const current = new Set(selectedScopes);
if (checked) {
current.add(scope);
} else {
current.delete(scope);
}
setValue("scopes", [...current], { shouldValidate: true });
};
const allScopesSelected
= supportedScopes.length > 0
&& selectedScopes.length === supportedScopes.length;
const toggleAllScopes = () => {
setValue(
"scopes",
allScopesSelected ? [] : [...supportedScopes],
{ shouldValidate: true },
);
};
const handleCreate = (formData: CreateFormData) => {
const connectionID = ConnectionHandler.getConnectionID(
viewer.id,
"OAuthTokensPage_oauth2AccessTokens",
);
void create({
variables: {
input: {
name: formData.name,
expiresAt: computeExpiresAt(formData.expiresIn).toISOString(),
scopes: formData.scopes,
},
connections: [connectionID],
},
onCompleted: (response) => {
const newToken = response.createOAuth2AccessToken?.token;
if (newToken) {
setToken(newToken);
tokenDialogRef.current?.open();
}
},
});
};
const handleDone = () => {
tokenDialogRef.current?.close();
void navigate("/me/oauth-tokens");
};
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("OAuth tokens"),
to: "/me/oauth-tokens",
},
{ label: __("New token") },
]}
/>
<PageHeader
title={__("New OAuth token")}
description={__(
"Create a bearer token with scoped access to the Probo API.",
)}
/>
<Card padded>
<form className="space-y-6" onSubmit={e => void handleSubmit(handleCreate)(e)}>
<div className="max-w-xl space-y-6">
<Field>
<Label htmlFor="name">{__("Name")}</Label>
<Input id="name" {...register("name")} />
{formState.errors.name && (
<p className="text-sm text-danger mt-1">
{formState.errors.name.message}
</p>
)}
</Field>
<Field>
<Label htmlFor="expiresIn">{__("Expiration")}</Label>
<Controller
name="expiresIn"
control={control}
render={({ field }) => (
<Select
id="expiresIn"
value={field.value}
onValueChange={field.onChange}
>
<Option value="1month">{__("1 month")}</Option>
<Option value="3months">{__("3 months")}</Option>
<Option value="6months">{__("6 months")}</Option>
<Option value="1year">{__("1 year")}</Option>
</Select>
)}
/>
</Field>
</div>
<Field>
<Label>{__("Scopes")}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-2">
{supportedScopes.map(scope => (
<label key={scope} className="flex items-start gap-2">
<Checkbox
checked={selectedScopes.includes(scope)}
onChange={checked => toggleScope(scope, checked)}
/>
<span className="min-w-0">
<span className="block font-medium">
{formatApiScopeLabel(scope)}
</span>
<span className="block text-sm text-txt-secondary break-all">
{scope}
</span>
</span>
</label>
))}
</div>
{formState.errors.scopes && (
<p className="text-sm text-danger mt-1">
{formState.errors.scopes.message}
</p>
)}
</Field>
<div className="flex gap-3 max-w-xl">
<Button type="submit" disabled={isCreating}>
{__("Create token")}
</Button>
<Button
type="button"
variant="secondary"
onClick={toggleAllScopes}
>
{allScopesSelected ? __("Deselect all") : __("Select all")}
</Button>
<Button variant="secondary" asChild>
<Link to="/me/oauth-tokens">
{__("Cancel")}
</Link>
</Button>
</div>
</form>
</Card>
<OAuthTokenCredentialsDialog
dialogRef={tokenDialogRef}
token={token}
onDone={handleDone}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
import { NewOAuthTokenPage } from "./NewOAuthTokenPage";
export default function NewOAuthTokenPageLoader() {
return (
<IAMRelayProvider>
<NewOAuthTokenPage />
</IAMRelayProvider>
);
}

View File

@@ -0,0 +1,171 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
IconChevronDown,
PageHeader,
Spinner,
Table,
Tbody,
Th,
Thead,
Tr,
} from "@probo/ui";
import {
graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { Link } from "react-router";
import type { OAuthTokensPageFragment$key } from "#/__generated__/iam/OAuthTokensPageFragment.graphql";
import type { OAuthTokensPageQuery } from "#/__generated__/iam/OAuthTokensPageQuery.graphql";
import type { OAuthTokensPageRefetchQuery } from "#/__generated__/iam/OAuthTokensPageRefetchQuery.graphql";
import { OAuthTokenRow } from "./_components/OAuthTokenRow";
export const oauthTokensPageQuery = graphql`
query OAuthTokensPageQuery {
viewer @required(action: THROW) {
id
canCreateOAuth2AccessToken: permission(
action: "iam:oauth2-access-token:create"
)
...OAuthTokensPageFragment
}
}
`;
const oauthTokensPageFragment = graphql`
fragment OAuthTokensPageFragment on Identity
@refetchable(queryName: "OAuthTokensPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
after: { type: "CursorKey" }
) {
oauth2AccessTokens(first: $first, after: $after)
@connection(key: "OAuthTokensPage_oauth2AccessTokens")
@required(action: THROW) {
edges {
node {
id
...OAuthTokenRowFragment
}
}
totalCount @required(action: THROW)
pageInfo {
hasNextPage
endCursor
}
}
}
`;
export function OAuthTokensPage(props: {
queryRef: PreloadedQuery<OAuthTokensPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
usePageTitle(__("OAuth tokens"));
const { viewer } = usePreloadedQuery(oauthTokensPageQuery, queryRef);
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
OAuthTokensPageRefetchQuery,
OAuthTokensPageFragment$key
>(oauthTokensPageFragment, viewer);
const tokens = data.oauth2AccessTokens.edges;
const totalCount = data.oauth2AccessTokens.totalCount;
return (
<div className="space-y-6 w-full py-6">
<PageHeader
title={__("OAuth tokens")}
description={__(
"Create bearer tokens with scoped API access for your account.",
)}
>
{viewer.canCreateOAuth2AccessToken && (
<Button asChild>
<Link to="/me/oauth-tokens/new">
{__("Create token")}
</Link>
</Button>
)}
</PageHeader>
{tokens.length === 0
? (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900 mb-2">
{__("No OAuth tokens")}
</h3>
<p className="text-gray-600">
{__(
"Create a token to authenticate API requests on your behalf.",
)}
</p>
</div>
</Card>
)
: (
<Card padded className="space-y-4">
{totalCount > tokens.length && (
<p className="text-sm text-txt-tertiary">
{`${__("Showing")} ${tokens.length} ${__("of")} ${totalCount}`}
</p>
)}
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Scopes")}</Th>
<Th>{__("Created")}</Th>
<Th>{__("Expires")}</Th>
<Th className="w-0" />
</Tr>
</Thead>
<Tbody>
{tokens.map(edge => (
<OAuthTokenRow
key={edge.node.id}
tokenKey={edge.node}
identityId={viewer.id}
/>
))}
</Tbody>
</Table>
{hasNext && (
<Button
variant="tertiary"
onClick={() => loadNext(50)}
className="mx-auto"
disabled={isLoadingNext}
icon={isLoadingNext ? Spinner : IconChevronDown}
>
{__("Show more")}
</Button>
)}
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { OAuthTokensPageQuery } from "#/__generated__/iam/OAuthTokensPageQuery.graphql";
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
import { OAuthTokensPage, oauthTokensPageQuery } from "./OAuthTokensPage";
import OAuthTokensPageSkeleton from "./OAuthTokensPageSkeleton";
function OAuthTokensPageLoaderInner() {
const [queryRef, loadQuery]
= useQueryLoader<OAuthTokensPageQuery>(oauthTokensPageQuery);
useEffect(() => {
loadQuery({}, { fetchPolicy: "store-and-network" });
}, [loadQuery]);
if (!queryRef) {
return <OAuthTokensPageSkeleton />;
}
return (
<Suspense fallback={<OAuthTokensPageSkeleton />}>
<OAuthTokensPage queryRef={queryRef} />
</Suspense>
);
}
export default function OAuthTokensPageLoader() {
return (
<IAMRelayProvider>
<OAuthTokensPageLoaderInner />
</IAMRelayProvider>
);
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export default function OAuthTokensPageSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="space-y-2">
<div className="h-8 w-48 rounded bg-bg-subtle" />
<div className="h-4 w-96 max-w-full rounded bg-bg-subtle" />
</div>
<div className="rounded-lg border border-border-low">
<div className="h-10 border-b border-border-low bg-bg-subtle" />
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 border-b border-border-low last:border-b-0" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useCopy } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
IconCheckmark1,
IconSquareBehindSquare2,
IconWarning,
} from "@probo/ui";
import { clsx } from "clsx";
export function OAuthTokenCredentialsDialog(props: {
dialogRef: React.RefObject<{ open: () => void; close: () => void } | null>;
token: string;
onDone: () => void;
}) {
const { dialogRef, token, onDone } = props;
const { __ } = useTranslate();
const [isCopied, copy] = useCopy();
return (
<Dialog
ref={dialogRef}
title={<Breadcrumb items={[__("OAuth tokens"), __("Token")]} />}
>
<DialogContent padded className="space-y-4">
<div className="flex items-start gap-2 rounded-lg border border-border-danger bg-danger px-4 py-3 text-sm text-txt-danger">
<IconWarning size={16} className="shrink-0 mt-0.5" />
<p>
{__(
"Copy this bearer token now. You will not be able to see it again.",
)}
</p>
</div>
<code className="flex items-start gap-2 rounded-lg bg-subtle p-4 font-mono text-sm">
<span className="break-all flex-1">{token}</span>
<button
type="button"
className={clsx(
"shrink-0 rounded p-1 hover:bg-bg-hover transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed",
isCopied && "text-success",
)}
onClick={() => copy(token)}
disabled={!token}
aria-label={isCopied ? __("Copied") : __("Copy")}
title={isCopied ? __("Copied") : __("Copy")}
>
{isCopied
? <IconCheckmark1 size={16} />
: <IconSquareBehindSquare2 size={16} />}
</button>
</code>
</DialogContent>
<DialogFooter>
<Button onClick={onDone}>{__("Done")}</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
Td,
Tr,
useToast,
} from "@probo/ui";
import * as Popover from "@radix-ui/react-popover";
import { ConnectionHandler, graphql, useFragment, useMutation } from "react-relay";
import type { OAuthTokenRowFragment$key } from "#/__generated__/iam/OAuthTokenRowFragment.graphql";
import type { OAuthTokenRowRevokeMutation } from "#/__generated__/iam/OAuthTokenRowRevokeMutation.graphql";
import { formatApiScopeLabel } from "./scopeLabels";
const VISIBLE_SCOPE_COUNT = 3;
const fragment = graphql`
fragment OAuthTokenRowFragment on OAuth2AccessToken {
id
name
scopes
expiresAt
createdAt
canDelete: permission(action: "iam:oauth2-access-token:delete")
}
`;
const revokeMutation = graphql`
mutation OAuthTokenRowRevokeMutation(
$input: RevokeOAuth2AccessTokenInput!
$connections: [ID!]!
) {
revokeOAuth2AccessToken(input: $input) {
oauth2AccessTokenId @deleteEdge(connections: $connections)
}
}
`;
export function OAuthTokenRow(props: {
tokenKey: OAuthTokenRowFragment$key;
identityId: string;
}) {
const { tokenKey, identityId } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const token = useFragment(fragment, tokenKey);
const visibleScopes = token.scopes.slice(0, VISIBLE_SCOPE_COUNT);
const hiddenScopes = token.scopes.slice(VISIBLE_SCOPE_COUNT);
const [revoke, isRevoking] = useMutation<OAuthTokenRowRevokeMutation>(revokeMutation);
const handleRevoke = () => {
const connectionID = ConnectionHandler.getConnectionID(
identityId,
"OAuthTokensPage_oauth2AccessTokens",
);
revoke({
variables: {
input: { oauth2AccessTokenId: token.id },
connections: [connectionID],
},
onCompleted: (_response, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to revoke OAuth token."),
errors as GraphQLError[],
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("OAuth token revoked."),
variant: "success",
});
},
onError: (error) => {
toast({
title: __("Error"),
description: formatError(__("Failed to revoke OAuth token."), error),
variant: "error",
});
},
});
};
return (
<Tr>
<Td className="font-medium">{token.name}</Td>
<Td noLink className="max-w-xs">
<div className="flex flex-wrap gap-1">
{visibleScopes.map(scope => (
<Badge key={scope} variant="neutral" className="text-xs">
{formatApiScopeLabel(scope)}
</Badge>
))}
{hiddenScopes.length > 0 && (
<Popover.Root>
<Popover.Trigger asChild>
<button type="button" className="inline-flex">
<Badge variant="neutral" className="text-xs cursor-pointer">
+
{hiddenScopes.length}
</Badge>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 rounded-md border bg-level-0 p-3 shadow-md max-w-sm"
sideOffset={4}
align="start"
>
<div className="flex flex-wrap gap-1">
{hiddenScopes.map(scope => (
<Badge key={scope} variant="neutral" className="text-xs">
{formatApiScopeLabel(scope)}
</Badge>
))}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)}
</div>
</Td>
<Td>{new Date(token.createdAt).toLocaleDateString()}</Td>
<Td>{new Date(token.expiresAt).toLocaleDateString()}</Td>
<Td>
{token.canDelete && (
<Dialog
title={__("Revoke OAuth token")}
trigger={(
<Button variant="danger" disabled={isRevoking}>
{__("Revoke")}
</Button>
)}
>
<DialogContent padded>
<p>
{__(
"This token will stop working immediately. This action cannot be undone.",
)}
</p>
</DialogContent>
<DialogFooter>
<Button variant="danger" onClick={handleRevoke} disabled={isRevoking}>
{__("Revoke token")}
</Button>
</DialogFooter>
</Dialog>
)}
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
const apiScopeLabels: Record<string, string> = {
"v1:access-review:read": "Read access reviews",
"v1:access-review": "Manage access reviews",
"v1:agent:read": "Read agents",
"v1:agent": "Manage agents",
"v1:asset:read": "Read assets",
"v1:asset": "Manage assets",
"v1:audit:read": "Read audits",
"v1:audit": "Manage audits",
"v1:common-third-party:read": "Read common third parties",
"v1:common-third-party": "Manage common third parties",
"v1:compliance-page:read": "Read compliance pages",
"v1:compliance-page": "Manage compliance pages",
"v1:connector:read": "Read connectors",
"v1:connector": "Manage connectors",
"v1:control:read": "Read controls",
"v1:control": "Manage controls",
"v1:datum:read": "Read data",
"v1:datum": "Manage data",
"v1:document:read": "Read documents",
"v1:document": "Manage documents",
"v1:iam:read": "Read IAM settings",
"v1:iam": "Manage IAM settings",
"v1:org:read": "Read organization",
"v1:org": "Manage organization",
"v1:privacy:read": "Read privacy settings",
"v1:privacy": "Manage privacy settings",
"v1:risk:read": "Read risks",
"v1:risk": "Manage risks",
"v1:slack-connection:read": "Read Slack connections",
"v1:slack-connection": "Manage Slack connections",
"v1:task:read": "Read tasks",
"v1:task": "Manage tasks",
"v1:third-party:read": "Read third parties",
"v1:third-party": "Manage third parties",
"v1:webhook:read": "Read webhooks",
"v1:webhook": "Manage webhooks",
};
export function formatApiScopeLabel(scope: string): string {
return apiScopeLabels[scope] ?? scope;
}

View File

@@ -38,6 +38,9 @@ export const fragment = graphql`
identity @required(action: THROW) {
email
canListAPIKeys: permission(action: "iam:personal-api-key:list")
canListOAuth2AccessTokens: permission(
action: "iam:oauth2-access-token:list"
)
}
}
}
@@ -63,7 +66,7 @@ export function ViewerMembershipDropdown(props: {
const {
viewer: {
fullName,
identity: { canListAPIKeys, email },
identity: { canListAPIKeys, canListOAuth2AccessTokens, email },
},
} = useFragment<ViewerMembershipDropdownFragment$key>(fragment, fKey);
const [signOut] = useMutation<ViewerMembershipDropdownSignOutMutation>(signOutMutation);
@@ -103,6 +106,13 @@ export function ViewerMembershipDropdown(props: {
label={__("API Keys")}
/>
)}
{canListOAuth2AccessTokens && (
<UserDropdownItem
to="/me/oauth-tokens"
icon={IconKey}
label={__("OAuth tokens")}
/>
)}
<UserDropdownItem
to={`/organizations/${organizationId}/employee`}
icon={IconPageTextLine}

View File

@@ -135,6 +135,18 @@ const routes = [
() => import("./pages/iam/apiKeys/APIKeysPageLoader"),
),
},
{
path: "me/oauth-tokens",
Component: lazy(
() => import("./pages/iam/oauthTokens/OAuthTokensPageLoader"),
),
},
{
path: "me/oauth-tokens/new",
Component: lazy(
() => import("./pages/iam/oauthTokens/NewOAuthTokenPageLoader"),
),
},
{
Component: CenteredLayout,
children: [