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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
301
apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPage.tsx
Normal file
301
apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
171
apps/console/src/pages/iam/oauthTokens/OAuthTokensPage.tsx
Normal file
171
apps/console/src/pages/iam/oauthTokens/OAuthTokensPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user