@carlonicora/nextjs-jsonapi 3.22.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{AssistantMessageInterface-CuaMDs2i.d.ts → AssistantMessageInterface-1Kbz8RVs.d.ts} +34 -1
- package/dist/{AssistantMessageInterface-Du3ShThp.d.mts → AssistantMessageInterface-CoxJyuFM.d.mts} +34 -1
- package/dist/{BlockNoteEditor-VEEMWB2J.js → BlockNoteEditor-PTD2V4CH.js} +19 -19
- package/dist/{BlockNoteEditor-VEEMWB2J.js.map → BlockNoteEditor-PTD2V4CH.js.map} +1 -1
- package/dist/{BlockNoteEditor-5QBGVWE7.mjs → BlockNoteEditor-Q2WEDMKJ.mjs} +4 -4
- package/dist/{BlockNoteViewer-ZY4ZF3HO.mjs → BlockNoteViewer-WO6GTDSI.mjs} +2 -2
- package/dist/{BlockNoteViewer-XCGBX4ST.js → BlockNoteViewer-WOXEJ6RM.js} +3 -3
- package/dist/{BlockNoteViewer-XCGBX4ST.js.map → BlockNoteViewer-WOXEJ6RM.js.map} +1 -1
- package/dist/{HandbookSectionInterface-DqU_d0sy.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +36 -0
- package/dist/{HandbookSectionInterface-DFj2zErb.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +36 -0
- package/dist/billing/index.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-CK47KSFV.mjs → chunk-2O3SFHQ7.mjs} +91 -39
- package/dist/chunk-2O3SFHQ7.mjs.map +1 -0
- package/dist/{chunk-SYQMVTIC.js → chunk-3D2MCVRZ.js} +7 -7
- package/dist/{chunk-SYQMVTIC.js.map → chunk-3D2MCVRZ.js.map} +1 -1
- package/dist/{chunk-XYSWOW5G.js → chunk-3YJF7QY4.js} +1358 -1116
- package/dist/chunk-3YJF7QY4.js.map +1 -0
- package/dist/{chunk-5G7I3OUB.mjs → chunk-GJHKOG5L.mjs} +1419 -1177
- package/dist/chunk-GJHKOG5L.mjs.map +1 -0
- package/dist/{chunk-U55JQ4NY.mjs → chunk-HAI7G6MD.mjs} +2 -2
- package/dist/{chunk-MWZCCOTS.js → chunk-IV4DTYXN.js} +91 -39
- package/dist/chunk-IV4DTYXN.js.map +1 -0
- package/dist/client/index.d.mts +13 -2
- package/dist/client/index.d.ts +13 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +90 -31
- package/dist/components/index.d.ts +90 -31
- package/dist/components/index.js +8 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +7 -3
- package/dist/contexts/index.d.mts +11 -2
- package/dist/contexts/index.d.ts +11 -2
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +29 -7
- package/dist/core/index.d.ts +29 -7
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +3 -4
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
- package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
- package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
- package/src/features/ai-connection/components/forms/AiConnectionEditor.tsx +1 -0
- package/src/features/ai-connection/components/lists/AiConnectionTypeCard.tsx +9 -0
- package/src/features/ai-connection/data/__tests__/ai-connection.test.ts +51 -0
- package/src/features/ai-connection/data/ai-connection.interface.ts +18 -0
- package/src/features/ai-connection/data/ai-connection.ts +63 -0
- package/src/features/ai-connection/i18n-keys.ts +9 -0
- package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
- package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
- package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
- package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
- package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
- package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
- package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
- package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
- package/src/features/assistant-message/components/index.ts +1 -0
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
- package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
- package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
- package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
- package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
- package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
- package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
- package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
- package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
- package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
- package/src/features/oauth/components/consent/index.ts +1 -0
- package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
- package/src/features/oauth/data/oauth.service.ts +3 -13
- package/src/features/oauth/data/oauth.ts +30 -1
- package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
- package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
- package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
- package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
- package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
- package/dist/chunk-5G7I3OUB.mjs.map +0 -1
- package/dist/chunk-CK47KSFV.mjs.map +0 -1
- package/dist/chunk-MWZCCOTS.js.map +0 -1
- package/dist/chunk-XYSWOW5G.js.map +0 -1
- /package/dist/{BlockNoteEditor-5QBGVWE7.mjs.map → BlockNoteEditor-Q2WEDMKJ.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-ZY4ZF3HO.mjs.map → BlockNoteViewer-WO6GTDSI.mjs.map} +0 -0
- /package/dist/{chunk-U55JQ4NY.mjs.map → chunk-HAI7G6MD.mjs.map} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { OAuthClientForm } from "../OAuthClientForm";
|
|
4
|
+
import { OAUTH_SCOPE_DISPLAY, OAuthClientInterface } from "../../interfaces/oauth.interface";
|
|
5
|
+
|
|
6
|
+
const scopeCheckbox = (name: string) => screen.getByRole("checkbox", { name: new RegExp(name) });
|
|
7
|
+
|
|
8
|
+
const makeClient = (allowedScopes: string[]): OAuthClientInterface =>
|
|
9
|
+
({
|
|
10
|
+
name: "Existing app",
|
|
11
|
+
description: undefined,
|
|
12
|
+
redirectUris: ["https://example.com/callback"],
|
|
13
|
+
allowedScopes,
|
|
14
|
+
isConfidential: true,
|
|
15
|
+
}) as unknown as OAuthClientInterface;
|
|
16
|
+
|
|
17
|
+
describe("OAuthClientForm", () => {
|
|
18
|
+
it("renders only the scopes passed in availableScopes", () => {
|
|
19
|
+
render(<OAuthClientForm onSubmit={vi.fn()} onCancel={vi.fn()} availableScopes={[OAUTH_SCOPE_DISPLAY.mcp]} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByText("MCP Server Access")).toBeTruthy();
|
|
22
|
+
expect(screen.queryByText("Read Access")).toBeNull();
|
|
23
|
+
expect(screen.queryByText("Write Access")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("preselects defaultScopes in create mode", () => {
|
|
27
|
+
render(
|
|
28
|
+
<OAuthClientForm
|
|
29
|
+
onSubmit={vi.fn()}
|
|
30
|
+
onCancel={vi.fn()}
|
|
31
|
+
availableScopes={[OAUTH_SCOPE_DISPLAY.read, OAUTH_SCOPE_DISPLAY.mcp]}
|
|
32
|
+
defaultScopes={["mcp"]}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(scopeCheckbox("MCP Server Access").getAttribute("aria-checked")).toBe("true");
|
|
37
|
+
expect(scopeCheckbox("Read Access").getAttribute("aria-checked")).toBe("false");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("uses client.allowedScopes instead of defaultScopes in edit mode", () => {
|
|
41
|
+
render(
|
|
42
|
+
<OAuthClientForm
|
|
43
|
+
client={makeClient(["read"])}
|
|
44
|
+
onSubmit={vi.fn()}
|
|
45
|
+
onCancel={vi.fn()}
|
|
46
|
+
availableScopes={[OAUTH_SCOPE_DISPLAY.read, OAUTH_SCOPE_DISPLAY.mcp]}
|
|
47
|
+
defaultScopes={["mcp"]}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(scopeCheckbox("Read Access").getAttribute("aria-checked")).toBe("true");
|
|
52
|
+
expect(scopeCheckbox("MCP Server Access").getAttribute("aria-checked")).toBe("false");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("offers the generic scope list by default, without Only35 scopes", () => {
|
|
56
|
+
render(<OAuthClientForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText("MCP Server Access")).toBeTruthy();
|
|
59
|
+
expect(screen.getByText("Read Access")).toBeTruthy();
|
|
60
|
+
expect(screen.queryByText("View Photographs")).toBeNull();
|
|
61
|
+
expect(screen.queryByText("View Rolls")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../shadcnui";
|
|
6
|
+
import { MicroLabel } from "../../../../components/typography";
|
|
7
|
+
|
|
8
|
+
export interface OAuthCompanySelectorProps {
|
|
9
|
+
/** The user's companies (studios) */
|
|
10
|
+
companies: Array<{ id: string; name: string }>;
|
|
11
|
+
/** The selected company id */
|
|
12
|
+
value: string | undefined;
|
|
13
|
+
/** Called with the newly selected company id */
|
|
14
|
+
onChange: (id: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Studio (company) picker for the OAuth consent screen.
|
|
19
|
+
* Renders nothing when the user belongs to a single company.
|
|
20
|
+
*/
|
|
21
|
+
export function OAuthCompanySelector({ companies, value, onChange }: OAuthCompanySelectorProps) {
|
|
22
|
+
const t = useTranslations();
|
|
23
|
+
|
|
24
|
+
const items = useMemo<Record<string, string>>(
|
|
25
|
+
() => Object.fromEntries(companies.map((company) => [company.id, company.name])),
|
|
26
|
+
[companies],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (companies.length <= 1) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="space-y-3">
|
|
33
|
+
<MicroLabel as="h3">{t("oauth.consent.company_label")}</MicroLabel>
|
|
34
|
+
<Select
|
|
35
|
+
items={items}
|
|
36
|
+
value={value ?? null}
|
|
37
|
+
onValueChange={(selected) => {
|
|
38
|
+
if (selected) onChange(String(selected));
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<SelectTrigger className="w-full" aria-label={t("oauth.consent.company_label")}>
|
|
42
|
+
<SelectValue placeholder={t("oauth.consent.company_label")} />
|
|
43
|
+
</SelectTrigger>
|
|
44
|
+
<SelectContent>
|
|
45
|
+
{companies.map((company) => (
|
|
46
|
+
<SelectItem key={company.id} value={company.id}>
|
|
47
|
+
{company.name}
|
|
48
|
+
</SelectItem>
|
|
49
|
+
))}
|
|
50
|
+
</SelectContent>
|
|
51
|
+
</Select>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
3
4
|
import { Button } from "../../../../shadcnui";
|
|
4
5
|
|
|
5
6
|
export interface OAuthConsentActionsProps {
|
|
@@ -15,13 +16,15 @@ export interface OAuthConsentActionsProps {
|
|
|
15
16
|
* Action buttons for OAuth consent screen
|
|
16
17
|
*/
|
|
17
18
|
export function OAuthConsentActions({ onApprove, onDeny, isLoading = false }: OAuthConsentActionsProps) {
|
|
19
|
+
const t = useTranslations();
|
|
20
|
+
|
|
18
21
|
return (
|
|
19
22
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
20
23
|
<Button variant="outline" onClick={onDeny} disabled={isLoading} className="flex-1">
|
|
21
|
-
|
|
24
|
+
{t("oauth.consent.deny")}
|
|
22
25
|
</Button>
|
|
23
26
|
<Button onClick={onApprove} disabled={isLoading} className="flex-1">
|
|
24
|
-
{isLoading ? "
|
|
27
|
+
{isLoading ? t("oauth.consent.authorizing") : t("oauth.consent.authorize")}
|
|
25
28
|
</Button>
|
|
26
29
|
</div>
|
|
27
30
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Shield } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ContentTitle } from "../../../../components";
|
|
4
6
|
import { OAuthClientInterface } from "../../interfaces/oauth.interface";
|
|
5
7
|
|
|
6
8
|
export interface OAuthConsentHeaderProps {
|
|
@@ -8,15 +10,17 @@ export interface OAuthConsentHeaderProps {
|
|
|
8
10
|
client: OAuthClientInterface;
|
|
9
11
|
/** Optional logo URL override */
|
|
10
12
|
logoUrl?: string;
|
|
11
|
-
/** Application name
|
|
12
|
-
appName
|
|
13
|
+
/** Application name shown in the header */
|
|
14
|
+
appName: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Header component for OAuth consent screen
|
|
17
19
|
* Shows platform logo and requesting app information
|
|
18
20
|
*/
|
|
19
|
-
export function OAuthConsentHeader({ client, logoUrl, appName
|
|
21
|
+
export function OAuthConsentHeader({ client, logoUrl, appName }: OAuthConsentHeaderProps) {
|
|
22
|
+
const t = useTranslations();
|
|
23
|
+
|
|
20
24
|
return (
|
|
21
25
|
<div className="text-center space-y-4">
|
|
22
26
|
{/* Platform Logo */}
|
|
@@ -32,9 +36,9 @@ export function OAuthConsentHeader({ client, logoUrl, appName = "Only35" }: OAut
|
|
|
32
36
|
|
|
33
37
|
{/* Authorization Request */}
|
|
34
38
|
<div className="space-y-2">
|
|
35
|
-
<
|
|
36
|
-
<p className="text-muted-foreground">
|
|
37
|
-
|
|
39
|
+
<ContentTitle element={t("oauth.consent.title", { client: client.name })} className="mb-0 justify-center" />
|
|
40
|
+
<p className="text-muted-foreground text-sm">
|
|
41
|
+
{t("oauth.consent.wants_access", { client: client.name, appName })}
|
|
38
42
|
</p>
|
|
39
43
|
</div>
|
|
40
44
|
</div>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { ExternalLink, AlertTriangle, Loader2 } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
4
5
|
import { Card, CardContent, CardFooter, Separator, Alert, AlertDescription } from "../../../../shadcnui";
|
|
5
6
|
import { OAuthConsentHeader } from "./OAuthConsentHeader";
|
|
6
7
|
import { OAuthScopeList } from "./OAuthScopeList";
|
|
7
8
|
import { OAuthConsentActions } from "./OAuthConsentActions";
|
|
9
|
+
import { OAuthCompanySelector } from "./OAuthCompanySelector";
|
|
8
10
|
import { useOAuthConsent } from "../../hooks/useOAuthConsent";
|
|
9
11
|
import { OAuthConsentRequest } from "../../interfaces/oauth.interface";
|
|
10
12
|
|
|
@@ -13,12 +15,17 @@ export interface OAuthConsentScreenProps {
|
|
|
13
15
|
params: OAuthConsentRequest;
|
|
14
16
|
/** Optional platform logo URL */
|
|
15
17
|
logoUrl?: string;
|
|
16
|
-
/** Platform name */
|
|
17
|
-
appName
|
|
18
|
+
/** Platform name, shown on the consent screen */
|
|
19
|
+
appName: string;
|
|
18
20
|
/** Terms of Service URL */
|
|
19
21
|
termsUrl?: string;
|
|
20
22
|
/** Privacy Policy URL */
|
|
21
23
|
privacyUrl?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The studio open in the host app's session, used as the default studio
|
|
26
|
+
* choice. Needed by apps that do not mount the package UserProvider.
|
|
27
|
+
*/
|
|
28
|
+
defaultCompanyId?: string;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
@@ -40,11 +47,16 @@ export interface OAuthConsentScreenProps {
|
|
|
40
47
|
export function OAuthConsentScreen({
|
|
41
48
|
params,
|
|
42
49
|
logoUrl,
|
|
43
|
-
appName
|
|
50
|
+
appName,
|
|
44
51
|
termsUrl = "/terms",
|
|
45
52
|
privacyUrl = "/privacy",
|
|
53
|
+
defaultCompanyId,
|
|
46
54
|
}: OAuthConsentScreenProps) {
|
|
47
|
-
const
|
|
55
|
+
const t = useTranslations();
|
|
56
|
+
const { clientInfo, isLoading, error, approve, deny, isSubmitting, companyId, setCompanyId } = useOAuthConsent(
|
|
57
|
+
params,
|
|
58
|
+
{ defaultCompanyId },
|
|
59
|
+
);
|
|
48
60
|
|
|
49
61
|
// Loading state
|
|
50
62
|
if (isLoading) {
|
|
@@ -53,7 +65,7 @@ export function OAuthConsentScreen({
|
|
|
53
65
|
<Card className="w-full max-w-md">
|
|
54
66
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
55
67
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
56
|
-
<p className="mt-4 text-muted-foreground">
|
|
68
|
+
<p className="mt-4 text-muted-foreground text-sm">{t("oauth.consent.loading")}</p>
|
|
57
69
|
</CardContent>
|
|
58
70
|
</Card>
|
|
59
71
|
</div>
|
|
@@ -68,9 +80,7 @@ export function OAuthConsentScreen({
|
|
|
68
80
|
<CardContent className="py-8">
|
|
69
81
|
<Alert variant="destructive">
|
|
70
82
|
<AlertTriangle className="h-4 w-4" />
|
|
71
|
-
<AlertDescription>
|
|
72
|
-
{error?.message || "Invalid authorization request. Please try again."}
|
|
73
|
-
</AlertDescription>
|
|
83
|
+
<AlertDescription>{error?.message || t("oauth.consent.invalid_request")}</AlertDescription>
|
|
74
84
|
</Alert>
|
|
75
85
|
</CardContent>
|
|
76
86
|
</Card>
|
|
@@ -79,6 +89,7 @@ export function OAuthConsentScreen({
|
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
const { client, scopes } = clientInfo;
|
|
92
|
+
const companies = clientInfo.companies ?? [];
|
|
82
93
|
|
|
83
94
|
return (
|
|
84
95
|
<div className="min-h-dvh flex items-center justify-center p-4 bg-muted/30">
|
|
@@ -94,11 +105,20 @@ export function OAuthConsentScreen({
|
|
|
94
105
|
|
|
95
106
|
<Separator />
|
|
96
107
|
|
|
108
|
+
{/* Studio */}
|
|
109
|
+
{companies.length > 1 && (
|
|
110
|
+
<>
|
|
111
|
+
<OAuthCompanySelector companies={companies} value={companyId} onChange={setCompanyId} />
|
|
112
|
+
|
|
113
|
+
<Separator />
|
|
114
|
+
</>
|
|
115
|
+
)}
|
|
116
|
+
|
|
97
117
|
{/* Redirect URI Notice */}
|
|
98
118
|
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
|
99
119
|
<ExternalLink className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
100
120
|
<div>
|
|
101
|
-
<span>
|
|
121
|
+
<span>{t("oauth.consent.redirect_notice")}</span>
|
|
102
122
|
<p className="tabular-nums text-xs mt-1 break-all">{params.redirectUri}</p>
|
|
103
123
|
</div>
|
|
104
124
|
</div>
|
|
@@ -110,13 +130,13 @@ export function OAuthConsentScreen({
|
|
|
110
130
|
{/* Footer */}
|
|
111
131
|
<CardFooter className="justify-center">
|
|
112
132
|
<p className="text-xs text-center text-muted-foreground">
|
|
113
|
-
|
|
133
|
+
{t("oauth.consent.agreement")}{" "}
|
|
114
134
|
<a href={termsUrl} className="hover:text-foreground" target="_blank" rel="noopener">
|
|
115
|
-
|
|
135
|
+
{t("oauth.consent.terms")}
|
|
116
136
|
</a>{" "}
|
|
117
|
-
and{" "}
|
|
137
|
+
{t("oauth.consent.and")}{" "}
|
|
118
138
|
<a href={privacyUrl} className="hover:text-foreground" target="_blank" rel="noopener">
|
|
119
|
-
|
|
139
|
+
{t("oauth.consent.privacy")}
|
|
120
140
|
</a>
|
|
121
141
|
.
|
|
122
142
|
</p>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
3
4
|
import { Eye, Pencil, Image, Upload, Film, FolderPlus, User, Shield, LucideIcon } from "lucide-react";
|
|
4
5
|
import { OAuthScopeInfo } from "../../interfaces/oauth.interface";
|
|
5
6
|
import { MicroLabel } from "../../../../components/typography";
|
|
@@ -25,13 +26,15 @@ const SCOPE_ICONS: Record<string, LucideIcon> = {
|
|
|
25
26
|
* List of requested OAuth scopes for consent display
|
|
26
27
|
*/
|
|
27
28
|
export function OAuthScopeList({ scopes }: OAuthScopeListProps) {
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
|
|
28
31
|
if (scopes.length === 0) {
|
|
29
32
|
return null;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
return (
|
|
33
36
|
<div className="space-y-3">
|
|
34
|
-
<MicroLabel as="h3">
|
|
37
|
+
<MicroLabel as="h3">{t("oauth.consent.scopes_heading")}</MicroLabel>
|
|
35
38
|
<ul className="space-y-3">
|
|
36
39
|
{scopes.map((scope) => {
|
|
37
40
|
const IconComponent = scope.icon ? SCOPE_ICONS[scope.icon] : Eye;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { OAuthConsentHeader } from "../OAuthConsentHeader";
|
|
4
|
+
|
|
5
|
+
const CLIENT = { id: "client-1", name: "Claude" } as any;
|
|
6
|
+
|
|
7
|
+
describe("OAuthConsentHeader", () => {
|
|
8
|
+
it("renders the consent title through ContentTitle (page-title recipe), not a raw <h1>", () => {
|
|
9
|
+
const { container } = render(<OAuthConsentHeader client={CLIENT} appName="Test" />);
|
|
10
|
+
|
|
11
|
+
expect(container.querySelector("h1")).toBeNull();
|
|
12
|
+
const title = screen.getByText("oauth.consent.title");
|
|
13
|
+
expect(title.className).toContain("text-primary text-3xl font-semibold");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the title centred with no extra bottom margin", () => {
|
|
17
|
+
const { container } = render(<OAuthConsentHeader client={CLIENT} appName="Test" />);
|
|
18
|
+
|
|
19
|
+
const wrapper = screen.getByText("oauth.consent.title").parentElement?.parentElement as HTMLElement;
|
|
20
|
+
expect(wrapper.className).toContain("justify-center");
|
|
21
|
+
expect(wrapper.className).toContain("mb-0");
|
|
22
|
+
expect(wrapper.className).not.toContain("mb-4");
|
|
23
|
+
expect(container.firstElementChild?.className).toContain("text-center");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { UseOAuthConsentReturn } from "../../../hooks/useOAuthConsent";
|
|
4
|
+
import { OAuthConsentScreen } from "../OAuthConsentScreen";
|
|
5
|
+
|
|
6
|
+
const { hookState, getAuthorizationInfo, approveAuthorization, currentUserContext } = vi.hoisted(() => ({
|
|
7
|
+
hookState: { value: null as UseOAuthConsentReturn | null },
|
|
8
|
+
getAuthorizationInfo: vi.fn(),
|
|
9
|
+
approveAuthorization: vi.fn(),
|
|
10
|
+
currentUserContext: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// The screen tests drive a controllable hook; the hook tests (hookState.value = null) run the real one.
|
|
14
|
+
vi.mock("../../../hooks/useOAuthConsent", async () => {
|
|
15
|
+
const actual = await vi.importActual<typeof import("../../../hooks/useOAuthConsent")>(
|
|
16
|
+
"../../../hooks/useOAuthConsent",
|
|
17
|
+
);
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
useOAuthConsent: (
|
|
21
|
+
params: Parameters<typeof actual.useOAuthConsent>[0],
|
|
22
|
+
options?: Parameters<typeof actual.useOAuthConsent>[1],
|
|
23
|
+
) => hookState.value ?? actual.useOAuthConsent(params, options),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock("../../../data/oauth.service", () => ({
|
|
28
|
+
OAuthService: {
|
|
29
|
+
getAuthorizationInfo: (params: unknown) => getAuthorizationInfo(params),
|
|
30
|
+
approveAuthorization: (params: unknown) => approveAuthorization(params),
|
|
31
|
+
denyAuthorization: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../../../../user/contexts/CurrentUserContext", () => ({
|
|
36
|
+
useCurrentUserContextOptional: () => currentUserContext(),
|
|
37
|
+
useCurrentUserContext: () => currentUserContext(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const PARAMS = { clientId: "client-1", redirectUri: "https://example.com/callback" };
|
|
41
|
+
const CLIENT = { id: "client-1", name: "Claude" } as any;
|
|
42
|
+
const C1 = { id: "c1", name: "A" };
|
|
43
|
+
const C2 = { id: "c2", name: "B" };
|
|
44
|
+
|
|
45
|
+
function makeHook(overrides: Partial<UseOAuthConsentReturn> = {}): UseOAuthConsentReturn {
|
|
46
|
+
return {
|
|
47
|
+
clientInfo: { client: CLIENT, scopes: [], companies: [C1] },
|
|
48
|
+
isLoading: false,
|
|
49
|
+
error: null,
|
|
50
|
+
approve: vi.fn(),
|
|
51
|
+
deny: vi.fn(),
|
|
52
|
+
isSubmitting: false,
|
|
53
|
+
companyId: "c1",
|
|
54
|
+
setCompanyId: vi.fn(),
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
hookState.value = null;
|
|
62
|
+
currentUserContext.mockReturnValue({ company: { id: "c2" } });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("OAuthConsentScreen", () => {
|
|
66
|
+
it("renders no studio picker for a single company", () => {
|
|
67
|
+
hookState.value = makeHook();
|
|
68
|
+
render(<OAuthConsentScreen params={PARAMS} appName="Test" />);
|
|
69
|
+
expect(screen.queryByLabelText("oauth.consent.company_label")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders the studio picker for two companies", () => {
|
|
73
|
+
hookState.value = makeHook({
|
|
74
|
+
clientInfo: { client: CLIENT, scopes: [], companies: [C1, C2] },
|
|
75
|
+
companyId: "c2",
|
|
76
|
+
});
|
|
77
|
+
render(<OAuthConsentScreen params={PARAMS} appName="Test" />);
|
|
78
|
+
expect(screen.getByLabelText("oauth.consent.company_label")).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByRole("heading", { name: "oauth.consent.company_label" })).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders translated keys, not hardcoded English", () => {
|
|
83
|
+
hookState.value = makeHook();
|
|
84
|
+
render(<OAuthConsentScreen params={PARAMS} appName="Test" />);
|
|
85
|
+
expect(screen.getByText("oauth.consent.authorize")).toBeInTheDocument();
|
|
86
|
+
expect(screen.queryByText("Authorize")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("useOAuthConsent", () => {
|
|
91
|
+
const renderConsentHook = async (options?: { defaultCompanyId?: string }) => {
|
|
92
|
+
const { useOAuthConsent } = await import("../../../hooks/useOAuthConsent");
|
|
93
|
+
return renderHook(() => useOAuthConsent(PARAMS, options));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
it("defaultCompanyId from the host app wins over the package user context", async () => {
|
|
97
|
+
currentUserContext.mockReturnValue(undefined);
|
|
98
|
+
getAuthorizationInfo.mockResolvedValue({ client: CLIENT, scopes: [], companies: [C1, C2] });
|
|
99
|
+
const { result } = await renderConsentHook({ defaultCompanyId: "c2" });
|
|
100
|
+
await waitFor(() => expect(result.current.companyId).toBe("c2"));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("defaults companyId to the current web-session company when it is in the list", async () => {
|
|
104
|
+
getAuthorizationInfo.mockResolvedValue({ client: CLIENT, scopes: [], companies: [C1, C2] });
|
|
105
|
+
const { result } = await renderConsentHook();
|
|
106
|
+
await waitFor(() => expect(result.current.companyId).toBe("c2"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("defaults to the first company when the current one is not in the list", async () => {
|
|
110
|
+
currentUserContext.mockReturnValue({ company: { id: "c9" } });
|
|
111
|
+
getAuthorizationInfo.mockResolvedValue({ client: CLIENT, scopes: [], companies: [C1, C2] });
|
|
112
|
+
const { result } = await renderConsentHook();
|
|
113
|
+
await waitFor(() => expect(result.current.companyId).toBe("c1"));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("approve sends the selected company", async () => {
|
|
117
|
+
getAuthorizationInfo.mockResolvedValue({ client: CLIENT, scopes: [], companies: [C1, C2] });
|
|
118
|
+
approveAuthorization.mockResolvedValue({ redirectUrl: "" });
|
|
119
|
+
const { result } = await renderConsentHook();
|
|
120
|
+
await waitFor(() => expect(result.current.companyId).toBe("c2"));
|
|
121
|
+
|
|
122
|
+
await result.current.approve();
|
|
123
|
+
|
|
124
|
+
expect(approveAuthorization).toHaveBeenCalledWith(expect.objectContaining({ companyId: "c2" }));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { AbstractService } from "../../../../core/abstracts/AbstractService";
|
|
4
|
+
import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
5
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
6
|
+
import { OAuthConsentRequest } from "../../interfaces/oauth.interface";
|
|
7
|
+
import { OAuthClient } from "../oauth";
|
|
8
|
+
import { OAuthService } from "../oauth.service";
|
|
9
|
+
|
|
10
|
+
// ModuleRegistry is backed by a globalThis symbol shared across test files in a
|
|
11
|
+
// worker, hence the register-if-absent guard (same idiom as HandbookThreadService.spec.ts).
|
|
12
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
13
|
+
try {
|
|
14
|
+
ModuleRegistry.get(key as any);
|
|
15
|
+
} catch {
|
|
16
|
+
ModuleRegistry.register(key, module);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
registerIfAbsent("OAuth", { name: "oauth-clients", model: OAuthClient } as any);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type CallApiParams = { method: string; endpoint: string; input?: unknown; overridesJsonApiCreation?: boolean };
|
|
25
|
+
const callApiWithMeta = () => vi.spyOn(AbstractService as any, "callApiWithMeta");
|
|
26
|
+
const firstCall = (spy: ReturnType<typeof callApiWithMeta>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
|
|
27
|
+
|
|
28
|
+
const fullRequest: OAuthConsentRequest = {
|
|
29
|
+
clientId: "client-1",
|
|
30
|
+
redirectUri: "https://example.test/cb",
|
|
31
|
+
scope: "read mcp",
|
|
32
|
+
state: "xyz",
|
|
33
|
+
codeChallenge: "challenge",
|
|
34
|
+
codeChallengeMethod: "S256",
|
|
35
|
+
companyId: "11111111-1111-4111-8111-111111111111",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("OAuthClient authorization bodies (RFC 6749 wire format)", () => {
|
|
39
|
+
it("builds the approve body with snake_case keys in wire order", () => {
|
|
40
|
+
const body = new OAuthClient().createApproveAuthorizationJsonApi(fullRequest);
|
|
41
|
+
|
|
42
|
+
expect(JSON.stringify(body)).toBe(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
client_id: "client-1",
|
|
45
|
+
redirect_uri: "https://example.test/cb",
|
|
46
|
+
scope: "read mcp",
|
|
47
|
+
state: "xyz",
|
|
48
|
+
code_challenge: "challenge",
|
|
49
|
+
code_challenge_method: "S256",
|
|
50
|
+
company_id: "11111111-1111-4111-8111-111111111111",
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("omits optional approve fields that were not supplied", () => {
|
|
56
|
+
const body = new OAuthClient().createApproveAuthorizationJsonApi({
|
|
57
|
+
clientId: "client-1",
|
|
58
|
+
redirectUri: "https://example.test/cb",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(JSON.stringify(body)).toBe('{"client_id":"client-1","redirect_uri":"https://example.test/cb"}');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("builds the deny body with only client_id, redirect_uri and state", () => {
|
|
65
|
+
const body = new OAuthClient().createDenyAuthorizationJsonApi(fullRequest);
|
|
66
|
+
|
|
67
|
+
expect(JSON.stringify(body)).toBe(
|
|
68
|
+
'{"client_id":"client-1","redirect_uri":"https://example.test/cb","state":"xyz"}',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("OAuthService consent calls", () => {
|
|
74
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
75
|
+
|
|
76
|
+
it("approveAuthorization sends the model's approve body and returns the redirect url", async () => {
|
|
77
|
+
const spy = callApiWithMeta().mockResolvedValue({ meta: { redirectUrl: "https://example.test/cb?code=1" } });
|
|
78
|
+
|
|
79
|
+
const result = await OAuthService.approveAuthorization(fullRequest);
|
|
80
|
+
|
|
81
|
+
const call = firstCall(spy);
|
|
82
|
+
expect(call.method).toBe("POST");
|
|
83
|
+
expect(call.endpoint).toContain("oauth/authorize/approve");
|
|
84
|
+
expect(call.overridesJsonApiCreation).toBe(true);
|
|
85
|
+
expect(call.input).toEqual(new OAuthClient().createApproveAuthorizationJsonApi(fullRequest));
|
|
86
|
+
expect(result).toEqual({ redirectUrl: "https://example.test/cb?code=1" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("denyAuthorization sends the model's deny body and returns the redirect url", async () => {
|
|
90
|
+
const spy = callApiWithMeta().mockResolvedValue({
|
|
91
|
+
meta: { redirectUrl: "https://example.test/cb?error=access_denied" },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await OAuthService.denyAuthorization(fullRequest);
|
|
95
|
+
|
|
96
|
+
const call = firstCall(spy);
|
|
97
|
+
expect(call.method).toBe("POST");
|
|
98
|
+
expect(call.endpoint).toContain("oauth/authorize/deny");
|
|
99
|
+
expect(call.overridesJsonApiCreation).toBe(true);
|
|
100
|
+
expect(call.input).toEqual(new OAuthClient().createDenyAuthorizationJsonApi(fullRequest));
|
|
101
|
+
expect(result).toEqual({ redirectUrl: "https://example.test/cb?error=access_denied" });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
OAuthConsentInfo,
|
|
8
8
|
OAuthConsentRequest,
|
|
9
9
|
} from "../interfaces/oauth.interface";
|
|
10
|
+
import { OAuthClient } from "./oauth";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Service for OAuth client management and authorization consent flow.
|
|
@@ -151,14 +152,7 @@ export class OAuthService extends AbstractService {
|
|
|
151
152
|
type: Modules.OAuth,
|
|
152
153
|
method: HttpMethod.POST,
|
|
153
154
|
endpoint: new EndpointCreator({ endpoint: "oauth/authorize/approve" }).generate(),
|
|
154
|
-
input:
|
|
155
|
-
client_id: params.clientId,
|
|
156
|
-
redirect_uri: params.redirectUri,
|
|
157
|
-
scope: params.scope,
|
|
158
|
-
state: params.state,
|
|
159
|
-
code_challenge: params.codeChallenge,
|
|
160
|
-
code_challenge_method: params.codeChallengeMethod,
|
|
161
|
-
},
|
|
155
|
+
input: new OAuthClient().createApproveAuthorizationJsonApi(params),
|
|
162
156
|
overridesJsonApiCreation: true,
|
|
163
157
|
});
|
|
164
158
|
|
|
@@ -176,11 +170,7 @@ export class OAuthService extends AbstractService {
|
|
|
176
170
|
type: Modules.OAuth,
|
|
177
171
|
method: HttpMethod.POST,
|
|
178
172
|
endpoint: new EndpointCreator({ endpoint: "oauth/authorize/deny" }).generate(),
|
|
179
|
-
input:
|
|
180
|
-
client_id: params.clientId,
|
|
181
|
-
redirect_uri: params.redirectUri,
|
|
182
|
-
state: params.state,
|
|
183
|
-
},
|
|
173
|
+
input: new OAuthClient().createDenyAuthorizationJsonApi(params),
|
|
184
174
|
overridesJsonApiCreation: true,
|
|
185
175
|
});
|
|
186
176
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
|
|
2
|
-
import { OAuthClientInput, OAuthClientInterface } from "../interfaces/oauth.interface";
|
|
2
|
+
import { OAuthClientInput, OAuthClientInterface, OAuthConsentRequest } from "../interfaces/oauth.interface";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* OAuth client data model
|
|
@@ -84,4 +84,33 @@ export class OAuthClient extends AbstractApiData implements OAuthClientInterface
|
|
|
84
84
|
|
|
85
85
|
return response;
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Body for POST /oauth/authorize/approve.
|
|
90
|
+
* The authorize endpoints speak the RFC 6749 wire format (snake_case keys,
|
|
91
|
+
* no JSON:API envelope), so this dedicated method owns that shape and the
|
|
92
|
+
* service sends it with `overridesJsonApiCreation`.
|
|
93
|
+
*/
|
|
94
|
+
createApproveAuthorizationJsonApi(params: OAuthConsentRequest) {
|
|
95
|
+
return {
|
|
96
|
+
client_id: params.clientId,
|
|
97
|
+
redirect_uri: params.redirectUri,
|
|
98
|
+
scope: params.scope,
|
|
99
|
+
state: params.state,
|
|
100
|
+
code_challenge: params.codeChallenge,
|
|
101
|
+
code_challenge_method: params.codeChallengeMethod,
|
|
102
|
+
company_id: params.companyId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Body for POST /oauth/authorize/deny (RFC 6749 wire format, see above).
|
|
108
|
+
*/
|
|
109
|
+
createDenyAuthorizationJsonApi(params: OAuthConsentRequest) {
|
|
110
|
+
return {
|
|
111
|
+
client_id: params.clientId,
|
|
112
|
+
redirect_uri: params.redirectUri,
|
|
113
|
+
state: params.state,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
87
116
|
}
|