@carlonicora/nextjs-jsonapi 1.24.2 → 1.25.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.
Files changed (116) hide show
  1. package/dist/{BlockNoteEditor-7OSPCSFW.js → BlockNoteEditor-CKMTHP7C.js} +13 -13
  2. package/dist/{BlockNoteEditor-7OSPCSFW.js.map → BlockNoteEditor-CKMTHP7C.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-63GKCJK3.mjs → BlockNoteEditor-EJQLNOLB.mjs} +3 -3
  4. package/dist/billing/index.js +345 -348
  5. package/dist/billing/index.js.map +1 -1
  6. package/dist/billing/index.mjs +6 -9
  7. package/dist/billing/index.mjs.map +1 -1
  8. package/dist/{chunk-UTPWUC6O.mjs → chunk-JNLXGGHE.mjs} +5790 -4519
  9. package/dist/chunk-JNLXGGHE.mjs.map +1 -0
  10. package/dist/{chunk-5U4NJJOF.mjs → chunk-LNBT2YPZ.mjs} +289 -2
  11. package/dist/chunk-LNBT2YPZ.mjs.map +1 -0
  12. package/dist/{chunk-NQVPCNRS.js → chunk-O3LLMGP7.js} +290 -3
  13. package/dist/chunk-O3LLMGP7.js.map +1 -0
  14. package/dist/{chunk-HIKTQMCR.js → chunk-YYZ2U4WU.js} +7332 -6061
  15. package/dist/chunk-YYZ2U4WU.js.map +1 -0
  16. package/dist/client/index.d.mts +96 -1
  17. package/dist/client/index.d.ts +96 -1
  18. package/dist/client/index.js +9 -3
  19. package/dist/client/index.js.map +1 -1
  20. package/dist/client/index.mjs +8 -2
  21. package/dist/components/index.d.mts +291 -32
  22. package/dist/components/index.d.ts +291 -32
  23. package/dist/components/index.js +43 -3
  24. package/dist/components/index.js.map +1 -1
  25. package/dist/components/index.mjs +58 -18
  26. package/dist/contexts/index.js +3 -3
  27. package/dist/contexts/index.mjs +2 -2
  28. package/dist/core/index.d.mts +108 -1
  29. package/dist/core/index.d.ts +108 -1
  30. package/dist/core/index.js +14 -2
  31. package/dist/core/index.js.map +1 -1
  32. package/dist/core/index.mjs +13 -1
  33. package/dist/index.d.mts +2 -1
  34. package/dist/index.d.ts +2 -1
  35. package/dist/index.js +14 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +13 -1
  38. package/dist/oauth.interface-DsZ5ecSX.d.mts +119 -0
  39. package/dist/oauth.interface-vL7za9Bz.d.ts +119 -0
  40. package/dist/scripts/generate-web-module/templates/components/editor.template.js +11 -13
  41. package/dist/scripts/generate-web-module/templates/components/editor.template.js.map +1 -1
  42. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.d.ts.map +1 -1
  43. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js +13 -26
  44. package/dist/scripts/generate-web-module/templates/components/multi-selector.template.js.map +1 -1
  45. package/dist/scripts/generate-web-module/templates/components/selector.template.d.ts.map +1 -1
  46. package/dist/scripts/generate-web-module/templates/components/selector.template.js +59 -76
  47. package/dist/scripts/generate-web-module/templates/components/selector.template.js.map +1 -1
  48. package/dist/scripts/generate-web-module/transformers/field-mapper.d.ts.map +1 -1
  49. package/dist/scripts/generate-web-module/transformers/field-mapper.js +10 -12
  50. package/dist/scripts/generate-web-module/transformers/field-mapper.js.map +1 -1
  51. package/dist/server/index.js +3 -3
  52. package/dist/server/index.mjs +1 -1
  53. package/package.json +1 -1
  54. package/scripts/generate-web-module/templates/components/editor.template.ts +11 -13
  55. package/scripts/generate-web-module/templates/components/multi-selector.template.ts +13 -26
  56. package/scripts/generate-web-module/templates/components/selector.template.ts +59 -76
  57. package/scripts/generate-web-module/transformers/field-mapper.ts +10 -12
  58. package/src/client/index.ts +1 -0
  59. package/src/components/forms/FormCheckbox.tsx +18 -24
  60. package/src/components/forms/FormDate.tsx +103 -116
  61. package/src/components/forms/FormDateTime.tsx +122 -130
  62. package/src/components/forms/FormFieldWrapper.tsx +54 -0
  63. package/src/components/forms/FormInput.tsx +58 -46
  64. package/src/components/forms/FormPassword.tsx +17 -24
  65. package/src/components/forms/FormPlaceAutocomplete.tsx +50 -75
  66. package/src/components/forms/FormSelect.tsx +29 -35
  67. package/src/components/forms/FormSlider.tsx +23 -27
  68. package/src/components/forms/FormSwitch.tsx +12 -14
  69. package/src/components/forms/FormTextarea.tsx +12 -19
  70. package/src/components/forms/index.ts +1 -1
  71. package/src/components/index.ts +1 -0
  72. package/src/core/index.ts +3 -0
  73. package/src/core/registry/ModuleRegistry.ts +2 -0
  74. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +9 -13
  75. package/src/features/company/components/forms/CompanyConfigurationSecurityForm.tsx +19 -33
  76. package/src/features/feature/components/forms/FormFeatures.tsx +3 -4
  77. package/src/features/index.ts +1 -0
  78. package/src/features/oauth/atoms/index.ts +1 -0
  79. package/src/features/oauth/atoms/oauth.atoms.ts +131 -0
  80. package/src/features/oauth/components/OAuthClientCard.tsx +105 -0
  81. package/src/features/oauth/components/OAuthClientDetail.tsx +269 -0
  82. package/src/features/oauth/components/OAuthClientForm.tsx +212 -0
  83. package/src/features/oauth/components/OAuthClientList.tsx +127 -0
  84. package/src/features/oauth/components/OAuthClientSecretDisplay.tsx +127 -0
  85. package/src/features/oauth/components/OAuthRedirectUriInput.tsx +152 -0
  86. package/src/features/oauth/components/OAuthScopeSelector.tsx +123 -0
  87. package/src/features/oauth/components/consent/OAuthConsentActions.tsx +41 -0
  88. package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +51 -0
  89. package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +142 -0
  90. package/src/features/oauth/components/consent/OAuthScopeList.tsx +72 -0
  91. package/src/features/oauth/components/consent/index.ts +4 -0
  92. package/src/features/oauth/components/index.ts +8 -0
  93. package/src/features/oauth/data/index.ts +2 -0
  94. package/src/features/oauth/data/oauth.service.ts +191 -0
  95. package/src/features/oauth/data/oauth.ts +87 -0
  96. package/src/features/oauth/hooks/index.ts +3 -0
  97. package/src/features/oauth/hooks/useOAuthClient.ts +161 -0
  98. package/src/features/oauth/hooks/useOAuthClients.ts +111 -0
  99. package/src/features/oauth/hooks/useOAuthConsent.ts +125 -0
  100. package/src/features/oauth/index.ts +6 -0
  101. package/src/features/oauth/interfaces/index.ts +1 -0
  102. package/src/features/oauth/interfaces/oauth.interface.ts +175 -0
  103. package/src/features/oauth/oauth.module.ts +9 -0
  104. package/src/features/role/components/forms/FormRoles.tsx +40 -51
  105. package/src/features/user/components/forms/UserMultiSelect.tsx +12 -29
  106. package/src/features/user/components/forms/UserSelector.tsx +79 -91
  107. package/src/shadcnui/index.ts +2 -0
  108. package/src/shadcnui/ui/field.tsx +3 -3
  109. package/src/shadcnui/ui/form.tsx +17 -134
  110. package/src/shadcnui/ui/input-group.tsx +4 -4
  111. package/dist/chunk-5U4NJJOF.mjs.map +0 -1
  112. package/dist/chunk-HIKTQMCR.js.map +0 -1
  113. package/dist/chunk-NQVPCNRS.js.map +0 -1
  114. package/dist/chunk-UTPWUC6O.mjs.map +0 -1
  115. package/src/components/forms/FormContainerGeneric.tsx +0 -39
  116. /package/dist/{BlockNoteEditor-63GKCJK3.mjs.map → BlockNoteEditor-EJQLNOLB.mjs.map} +0 -0
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+ import {
5
+ Button,
6
+ Input,
7
+ Label,
8
+ Textarea,
9
+ RadioGroup,
10
+ RadioGroupItem,
11
+ Card,
12
+ CardContent,
13
+ CardDescription,
14
+ CardFooter,
15
+ CardHeader,
16
+ CardTitle,
17
+ } from "../../../shadcnui";
18
+ import { OAuthRedirectUriInput } from "./OAuthRedirectUriInput";
19
+ import { OAuthScopeSelector } from "./OAuthScopeSelector";
20
+ import { OAuthClientCreateRequest, OAuthClientInterface, DEFAULT_GRANT_TYPES } from "../interfaces/oauth.interface";
21
+
22
+ export interface OAuthClientFormProps {
23
+ /** Existing client for edit mode (undefined = create mode) */
24
+ client?: OAuthClientInterface;
25
+ /** Called on form submit */
26
+ onSubmit: (data: OAuthClientCreateRequest) => Promise<void>;
27
+ /** Called on cancel */
28
+ onCancel: () => void;
29
+ /** Whether form is submitting */
30
+ isLoading?: boolean;
31
+ }
32
+
33
+ interface FormState {
34
+ name: string;
35
+ description: string;
36
+ redirectUris: string[];
37
+ allowedScopes: string[];
38
+ isConfidential: boolean;
39
+ }
40
+
41
+ interface FormErrors {
42
+ name?: string;
43
+ redirectUris?: string;
44
+ allowedScopes?: string;
45
+ }
46
+
47
+ /**
48
+ * Form for creating or editing an OAuth client
49
+ */
50
+ export function OAuthClientForm({
51
+ client,
52
+ onSubmit,
53
+ onCancel,
54
+ isLoading = false,
55
+ }: OAuthClientFormProps) {
56
+ const isEditMode = !!client;
57
+
58
+ const [formState, setFormState] = useState<FormState>({
59
+ name: client?.name || "",
60
+ description: client?.description || "",
61
+ redirectUris: client?.redirectUris?.length ? client.redirectUris : [""],
62
+ allowedScopes: client?.allowedScopes || [],
63
+ isConfidential: client?.isConfidential ?? true,
64
+ });
65
+
66
+ const [errors, setErrors] = useState<FormErrors>({});
67
+
68
+ const validate = useCallback((): boolean => {
69
+ const newErrors: FormErrors = {};
70
+
71
+ if (!formState.name.trim()) {
72
+ newErrors.name = "Application name is required";
73
+ }
74
+
75
+ const validUris = formState.redirectUris.filter((uri) => uri.trim());
76
+ if (validUris.length === 0) {
77
+ newErrors.redirectUris = "At least one redirect URI is required";
78
+ }
79
+
80
+ if (formState.allowedScopes.length === 0) {
81
+ newErrors.allowedScopes = "At least one scope must be selected";
82
+ }
83
+
84
+ setErrors(newErrors);
85
+ return Object.keys(newErrors).length === 0;
86
+ }, [formState]);
87
+
88
+ const handleSubmit = useCallback(
89
+ async (e: React.FormEvent) => {
90
+ e.preventDefault();
91
+
92
+ if (!validate()) return;
93
+
94
+ const data: OAuthClientCreateRequest = {
95
+ name: formState.name.trim(),
96
+ description: formState.description.trim() || undefined,
97
+ redirectUris: formState.redirectUris.filter((uri) => uri.trim()),
98
+ allowedScopes: formState.allowedScopes,
99
+ allowedGrantTypes: DEFAULT_GRANT_TYPES,
100
+ isConfidential: formState.isConfidential,
101
+ };
102
+
103
+ await onSubmit(data);
104
+ },
105
+ [formState, validate, onSubmit]
106
+ );
107
+
108
+ return (
109
+ <Card>
110
+ <form onSubmit={handleSubmit}>
111
+ <CardHeader>
112
+ <CardTitle>{isEditMode ? "Edit Application" : "Create OAuth Application"}</CardTitle>
113
+ <CardDescription>
114
+ {isEditMode
115
+ ? "Update your OAuth application settings."
116
+ : "Register a new application to access the API."}
117
+ </CardDescription>
118
+ </CardHeader>
119
+ <CardContent className="space-y-6">
120
+ {/* Name */}
121
+ <div className="space-y-2">
122
+ <Label htmlFor="name">Application Name *</Label>
123
+ <Input
124
+ id="name"
125
+ value={formState.name}
126
+ onChange={(e) => setFormState((s) => ({ ...s, name: e.target.value }))}
127
+ placeholder="My Lightroom Plugin"
128
+ disabled={isLoading}
129
+ className={errors.name ? "border-destructive" : ""}
130
+ />
131
+ {errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
132
+ </div>
133
+
134
+ {/* Description */}
135
+ <div className="space-y-2">
136
+ <Label htmlFor="description">Description</Label>
137
+ <Textarea
138
+ id="description"
139
+ value={formState.description}
140
+ onChange={(e) => setFormState((s) => ({ ...s, description: e.target.value }))}
141
+ placeholder="A brief description of your application"
142
+ disabled={isLoading}
143
+ rows={3}
144
+ />
145
+ </div>
146
+
147
+ {/* Redirect URIs */}
148
+ <OAuthRedirectUriInput
149
+ value={formState.redirectUris}
150
+ onChange={(uris) => setFormState((s) => ({ ...s, redirectUris: uris }))}
151
+ error={errors.redirectUris}
152
+ disabled={isLoading}
153
+ />
154
+
155
+ {/* Scopes */}
156
+ <OAuthScopeSelector
157
+ value={formState.allowedScopes}
158
+ onChange={(scopes) => setFormState((s) => ({ ...s, allowedScopes: scopes }))}
159
+ error={errors.allowedScopes}
160
+ disabled={isLoading}
161
+ />
162
+
163
+ {/* Client Type */}
164
+ <div className="space-y-3">
165
+ <Label>Client Type</Label>
166
+ <RadioGroup
167
+ value={formState.isConfidential ? "confidential" : "public"}
168
+ onValueChange={(v) => setFormState((s) => ({ ...s, isConfidential: v === "confidential" }))}
169
+ disabled={isLoading || isEditMode}
170
+ >
171
+ <div className="flex items-start space-x-3 p-3 rounded-md border">
172
+ <RadioGroupItem value="confidential" id="confidential" className="mt-1" />
173
+ <div>
174
+ <Label htmlFor="confidential" className="font-medium cursor-pointer">
175
+ Confidential
176
+ </Label>
177
+ <p className="text-sm text-muted-foreground">
178
+ Server-side application that can securely store the client secret.
179
+ </p>
180
+ </div>
181
+ </div>
182
+ <div className="flex items-start space-x-3 p-3 rounded-md border">
183
+ <RadioGroupItem value="public" id="public" className="mt-1" />
184
+ <div>
185
+ <Label htmlFor="public" className="font-medium cursor-pointer">
186
+ Public
187
+ </Label>
188
+ <p className="text-sm text-muted-foreground">
189
+ Mobile or desktop application. Requires PKCE for authorization.
190
+ </p>
191
+ </div>
192
+ </div>
193
+ </RadioGroup>
194
+ {isEditMode && (
195
+ <p className="text-sm text-muted-foreground">
196
+ Client type cannot be changed after creation.
197
+ </p>
198
+ )}
199
+ </div>
200
+ </CardContent>
201
+ <CardFooter className="flex justify-end gap-3">
202
+ <Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
203
+ Cancel
204
+ </Button>
205
+ <Button type="submit" disabled={isLoading}>
206
+ {isLoading ? "Saving..." : isEditMode ? "Save Changes" : "Create Application"}
207
+ </Button>
208
+ </CardFooter>
209
+ </form>
210
+ </Card>
211
+ );
212
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { Plus, Key } from "lucide-react";
4
+ import { Button, Skeleton } from "../../../shadcnui";
5
+ import { OAuthClientCard } from "./OAuthClientCard";
6
+ import { OAuthClientInterface } from "../interfaces/oauth.interface";
7
+
8
+ export interface OAuthClientListProps {
9
+ /** List of OAuth clients */
10
+ clients: OAuthClientInterface[];
11
+ /** Whether list is loading */
12
+ isLoading?: boolean;
13
+ /** Error to display */
14
+ error?: Error | null;
15
+ /** Called when a client is clicked */
16
+ onClientClick?: (client: OAuthClientInterface) => void;
17
+ /** Called when create button is clicked */
18
+ onCreateClick?: () => void;
19
+ /** Called when edit is clicked on a client */
20
+ onEditClick?: (client: OAuthClientInterface) => void;
21
+ /** Called when delete is clicked on a client */
22
+ onDeleteClick?: (client: OAuthClientInterface) => void;
23
+ /** Message to show when list is empty */
24
+ emptyStateMessage?: string;
25
+ /** Title for the list */
26
+ title?: string;
27
+ }
28
+
29
+ /**
30
+ * Component for displaying a list of OAuth clients
31
+ */
32
+ export function OAuthClientList({
33
+ clients,
34
+ isLoading = false,
35
+ error,
36
+ onClientClick,
37
+ onCreateClick,
38
+ onEditClick,
39
+ onDeleteClick,
40
+ emptyStateMessage = "No OAuth applications yet. Create one to get started.",
41
+ title = "OAuth Applications",
42
+ }: OAuthClientListProps) {
43
+ // Loading state
44
+ if (isLoading && clients.length === 0) {
45
+ return (
46
+ <div className="space-y-4">
47
+ <div className="flex items-center justify-between">
48
+ <h2 className="text-2xl font-bold">{title}</h2>
49
+ <Skeleton className="h-10 w-32" />
50
+ </div>
51
+ <div className="space-y-3">
52
+ {[1, 2, 3].map((i) => (
53
+ <Skeleton key={i} className="h-32 w-full" />
54
+ ))}
55
+ </div>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // Error state
61
+ if (error) {
62
+ return (
63
+ <div className="space-y-4">
64
+ <div className="flex items-center justify-between">
65
+ <h2 className="text-2xl font-bold">{title}</h2>
66
+ </div>
67
+ <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6 text-center">
68
+ <p className="text-destructive">{error.message}</p>
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ // Empty state
75
+ if (clients.length === 0) {
76
+ return (
77
+ <div className="space-y-4">
78
+ <div className="flex items-center justify-between">
79
+ <h2 className="text-2xl font-bold">{title}</h2>
80
+ {onCreateClick && (
81
+ <Button onClick={onCreateClick}>
82
+ <Plus className="h-4 w-4 mr-2" />
83
+ New App
84
+ </Button>
85
+ )}
86
+ </div>
87
+ <div className="rounded-lg border border-dashed p-12 text-center">
88
+ <Key className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
89
+ <h3 className="text-lg font-medium mb-2">No OAuth Applications</h3>
90
+ <p className="text-muted-foreground mb-4">{emptyStateMessage}</p>
91
+ {onCreateClick && (
92
+ <Button onClick={onCreateClick}>
93
+ <Plus className="h-4 w-4 mr-2" />
94
+ Create Application
95
+ </Button>
96
+ )}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ // List view
103
+ return (
104
+ <div className="space-y-4">
105
+ <div className="flex items-center justify-between">
106
+ <h2 className="text-2xl font-bold">{title}</h2>
107
+ {onCreateClick && (
108
+ <Button onClick={onCreateClick}>
109
+ <Plus className="h-4 w-4 mr-2" />
110
+ New App
111
+ </Button>
112
+ )}
113
+ </div>
114
+ <div className="space-y-3">
115
+ {clients.map((client) => (
116
+ <OAuthClientCard
117
+ key={client.id || client.clientId}
118
+ client={client}
119
+ onClick={() => onClientClick?.(client)}
120
+ onEdit={onEditClick ? () => onEditClick(client) : undefined}
121
+ onDelete={onDeleteClick ? () => onDeleteClick(client) : undefined}
122
+ />
123
+ ))}
124
+ </div>
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+ import { Copy, Check, AlertTriangle } from "lucide-react";
5
+ import {
6
+ Button,
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ Alert,
14
+ AlertDescription,
15
+ Input,
16
+ } from "../../../shadcnui";
17
+
18
+ export interface OAuthClientSecretDisplayProps {
19
+ /** The client secret to display */
20
+ secret: string;
21
+ /** Called when user dismisses the dialog */
22
+ onDismiss: () => void;
23
+ /** Whether the dialog is open */
24
+ open: boolean;
25
+ /** Optional client name for context */
26
+ clientName?: string;
27
+ }
28
+
29
+ /**
30
+ * Modal dialog for displaying a client secret ONE TIME
31
+ * Shows warning that secret cannot be retrieved again
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * const [secret, setSecret] = useAtom(oauthNewClientSecretAtom);
36
+ *
37
+ * <OAuthClientSecretDisplay
38
+ * secret={secret || ''}
39
+ * open={!!secret}
40
+ * onDismiss={() => setSecret(null)}
41
+ * />
42
+ * ```
43
+ */
44
+ export function OAuthClientSecretDisplay({
45
+ secret,
46
+ onDismiss,
47
+ open,
48
+ clientName,
49
+ }: OAuthClientSecretDisplayProps) {
50
+ const [copied, setCopied] = useState(false);
51
+
52
+ const handleCopy = useCallback(async () => {
53
+ try {
54
+ await navigator.clipboard.writeText(secret);
55
+ setCopied(true);
56
+ setTimeout(() => setCopied(false), 2000);
57
+ } catch (err) {
58
+ console.error("Failed to copy to clipboard:", err);
59
+ }
60
+ }, [secret]);
61
+
62
+ const handleDismiss = useCallback(() => {
63
+ setCopied(false);
64
+ onDismiss();
65
+ }, [onDismiss]);
66
+
67
+ return (
68
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleDismiss()}>
69
+ <DialogContent className="sm:max-w-md">
70
+ <DialogHeader>
71
+ <DialogTitle className="flex items-center gap-2">
72
+ <AlertTriangle className="h-5 w-5 text-warning" />
73
+ Save Your Client Secret
74
+ </DialogTitle>
75
+ <DialogDescription>
76
+ {clientName
77
+ ? `Your client secret for "${clientName}" is shown below.`
78
+ : "Your client secret is shown below."}
79
+ </DialogDescription>
80
+ </DialogHeader>
81
+
82
+ <Alert variant="destructive" className="my-4">
83
+ <AlertTriangle className="h-4 w-4" />
84
+ <AlertDescription>
85
+ <strong>This is the only time your client secret will be displayed.</strong>
86
+ <br />
87
+ Copy it now and store it securely. You will not be able to retrieve it later.
88
+ </AlertDescription>
89
+ </Alert>
90
+
91
+ <div className="flex items-center space-x-2">
92
+ <div className="flex-1">
93
+ <Input
94
+ value={secret}
95
+ readOnly
96
+ className="font-mono text-sm"
97
+ onClick={(e) => e.currentTarget.select()}
98
+ />
99
+ </div>
100
+ <Button
101
+ type="button"
102
+ variant="outline"
103
+ size="icon"
104
+ onClick={handleCopy}
105
+ title={copied ? "Copied!" : "Copy to clipboard"}
106
+ >
107
+ {copied ? (
108
+ <Check className="h-4 w-4 text-green-600" />
109
+ ) : (
110
+ <Copy className="h-4 w-4" />
111
+ )}
112
+ </Button>
113
+ </div>
114
+
115
+ {copied && (
116
+ <p className="text-sm text-green-600 text-center">Copied to clipboard!</p>
117
+ )}
118
+
119
+ <DialogFooter className="mt-4">
120
+ <Button onClick={handleDismiss} className="w-full">
121
+ I've Saved My Secret
122
+ </Button>
123
+ </DialogFooter>
124
+ </DialogContent>
125
+ </Dialog>
126
+ );
127
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { Plus, Trash2 } from "lucide-react";
5
+ import { Button, Input, Label } from "../../../shadcnui";
6
+
7
+ export interface OAuthRedirectUriInputProps {
8
+ /** Current array of redirect URIs */
9
+ value: string[];
10
+ /** Called when URIs change */
11
+ onChange: (uris: string[]) => void;
12
+ /** Error message to display */
13
+ error?: string;
14
+ /** Whether input is disabled */
15
+ disabled?: boolean;
16
+ /** Label text */
17
+ label?: string;
18
+ }
19
+
20
+ /**
21
+ * Validates a redirect URI
22
+ * Allows: https://, http://localhost, custom schemes (myapp://)
23
+ */
24
+ function isValidRedirectUri(uri: string): boolean {
25
+ if (!uri.trim()) return false;
26
+
27
+ // Allow localhost for development
28
+ if (uri.startsWith("http://localhost") || uri.startsWith("http://127.0.0.1")) {
29
+ return true;
30
+ }
31
+
32
+ // Require HTTPS for non-localhost
33
+ if (uri.startsWith("https://")) {
34
+ try {
35
+ new URL(uri);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // Allow custom schemes (e.g., myapp://oauth/callback)
43
+ if (uri.includes("://")) {
44
+ const schemeMatch = uri.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
45
+ return schemeMatch !== null;
46
+ }
47
+
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Dynamic input for managing OAuth redirect URIs
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * const [redirectUris, setRedirectUris] = useState<string[]>(['']);
57
+ *
58
+ * <OAuthRedirectUriInput
59
+ * value={redirectUris}
60
+ * onChange={setRedirectUris}
61
+ * error={errors.redirectUris}
62
+ * />
63
+ * ```
64
+ */
65
+ export function OAuthRedirectUriInput({
66
+ value,
67
+ onChange,
68
+ error,
69
+ disabled = false,
70
+ label = "Redirect URIs",
71
+ }: OAuthRedirectUriInputProps) {
72
+ const handleAdd = useCallback(() => {
73
+ onChange([...value, ""]);
74
+ }, [value, onChange]);
75
+
76
+ const handleRemove = useCallback(
77
+ (index: number) => {
78
+ const newUris = value.filter((_, i) => i !== index);
79
+ // Keep at least one empty input
80
+ onChange(newUris.length > 0 ? newUris : [""]);
81
+ },
82
+ [value, onChange]
83
+ );
84
+
85
+ const handleChange = useCallback(
86
+ (index: number, newValue: string) => {
87
+ const newUris = [...value];
88
+ newUris[index] = newValue;
89
+ onChange(newUris);
90
+ },
91
+ [value, onChange]
92
+ );
93
+
94
+ return (
95
+ <div className="space-y-2">
96
+ <Label>{label} *</Label>
97
+ <p className="text-sm text-muted-foreground">
98
+ Enter the URIs where users will be redirected after authorization.
99
+ Use https:// for production, or custom schemes for mobile apps.
100
+ </p>
101
+
102
+ <div className="space-y-2">
103
+ {value.map((uri, index) => {
104
+ const isValid = !uri || isValidRedirectUri(uri);
105
+
106
+ return (
107
+ <div key={index} className="flex gap-2">
108
+ <div className="flex-1">
109
+ <Input
110
+ value={uri}
111
+ onChange={(e) => handleChange(index, e.target.value)}
112
+ placeholder="https://example.com/callback or myapp://oauth"
113
+ disabled={disabled}
114
+ className={!isValid ? "border-destructive" : ""}
115
+ />
116
+ {!isValid && (
117
+ <p className="text-xs text-destructive mt-1">
118
+ Must be https://, http://localhost, or a custom scheme (app://)
119
+ </p>
120
+ )}
121
+ </div>
122
+ <Button
123
+ type="button"
124
+ variant="ghost"
125
+ size="icon"
126
+ onClick={() => handleRemove(index)}
127
+ disabled={disabled || value.length === 1}
128
+ title="Remove URI"
129
+ >
130
+ <Trash2 className="h-4 w-4" />
131
+ </Button>
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+
137
+ <Button
138
+ type="button"
139
+ variant="outline"
140
+ size="sm"
141
+ onClick={handleAdd}
142
+ disabled={disabled}
143
+ className="mt-2"
144
+ >
145
+ <Plus className="h-4 w-4 mr-2" />
146
+ Add Redirect URI
147
+ </Button>
148
+
149
+ {error && <p className="text-sm text-destructive">{error}</p>}
150
+ </div>
151
+ );
152
+ }