Scopes.tsx raw
1 import {
2 ArrowDownUpIcon,
3 BrickWallIcon,
4 ChevronsUpDownIcon,
5 LucideIcon,
6 MoveDownIcon,
7 SquarePenIcon,
8 } from "lucide-react";
9 import React from "react";
10 import { Button } from "src/components/ui/button";
11 import { Checkbox } from "src/components/ui/checkbox";
12 import { Label } from "src/components/ui/label";
13 import {
14 Sheet,
15 SheetClose,
16 SheetContent,
17 SheetFooter,
18 SheetHeader,
19 SheetTitle,
20 } from "src/components/ui/sheet";
21 import { cn } from "src/lib/utils";
22 import {
23 READ_ONLY_SCOPES,
24 Scope,
25 WalletCapabilities,
26 scopeDescriptions,
27 } from "src/types";
28
29 const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const;
30 type ScopeGroup = (typeof scopeGroups)[number];
31 type ScopeGroupIconMap = { [key in ScopeGroup]: LucideIcon };
32
33 const scopeGroupIconMap: ScopeGroupIconMap = {
34 full_access: ArrowDownUpIcon,
35 read_only: MoveDownIcon,
36 isolated: BrickWallIcon,
37 custom: SquarePenIcon,
38 };
39
40 const scopeGroupTitle: Record<ScopeGroup, string> = {
41 full_access: "Full Access",
42 read_only: "Read Only",
43 isolated: "Isolated App",
44 custom: "Custom",
45 };
46
47 const scopeGroupDescriptions: Record<ScopeGroup, string> = {
48 full_access: "Send and receive payments",
49 read_only: "Receive payments and view history",
50 isolated: "Separate wallet with isolated balance",
51 custom: "Define specific permissions",
52 };
53
54 interface ScopesProps {
55 capabilities: WalletCapabilities;
56 scopes: Scope[];
57 isolated: boolean;
58 onScopesChanged: (scopes: Scope[], isolated: boolean) => void;
59 }
60
61 const Scopes: React.FC<ScopesProps> = ({
62 capabilities,
63 scopes,
64 isolated,
65 onScopesChanged,
66 }) => {
67 const [isSheetOpen, setSheetOpen] = React.useState(false);
68 const fullAccessScopes: Scope[] = React.useMemo(() => {
69 return [...capabilities.scopes];
70 }, [capabilities.scopes]);
71
72 const readOnlyScopes: Scope[] = React.useMemo(() => {
73 return capabilities.scopes.filter((scope) =>
74 READ_ONLY_SCOPES.includes(scope)
75 );
76 }, [capabilities.scopes]);
77
78 const isolatedScopes: Scope[] = React.useMemo(() => {
79 const isolatedScopes: Scope[] = [
80 "pay_invoice",
81 "get_balance",
82 "get_info",
83 "make_invoice",
84 "lookup_invoice",
85 "list_transactions",
86 "notifications",
87 ];
88
89 return capabilities.scopes.filter((scope) =>
90 isolatedScopes.includes(scope)
91 );
92 }, [capabilities.scopes]);
93
94 const [scopeGroup, setScopeGroup] = React.useState<ScopeGroup>(() => {
95 if (
96 isolated &&
97 scopes.length === isolatedScopes.length &&
98 scopes.every((scope) => isolatedScopes.includes(scope))
99 ) {
100 return "isolated";
101 }
102 if (
103 scopes.length === fullAccessScopes.length &&
104 scopes.every((scope) => fullAccessScopes.includes(scope))
105 ) {
106 return "full_access";
107 }
108 if (
109 scopes.length === readOnlyScopes.length &&
110 readOnlyScopes.every((readOnlyScope) => scopes.includes(readOnlyScope))
111 ) {
112 return "read_only";
113 }
114
115 return "custom";
116 });
117
118 const handleScopeGroupChange = (scopeGroup: ScopeGroup) => {
119 setScopeGroup(scopeGroup);
120 switch (scopeGroup) {
121 case "full_access":
122 onScopesChanged(fullAccessScopes, false);
123 break;
124 case "read_only":
125 onScopesChanged(readOnlyScopes, false);
126 break;
127 case "isolated":
128 onScopesChanged(isolatedScopes, true);
129 break;
130 default: {
131 onScopesChanged([], false);
132 break;
133 }
134 }
135 };
136
137 const handleScopeChange = (scope: Scope) => {
138 let newScopes = [...scopes];
139 if (newScopes.includes(scope)) {
140 newScopes = newScopes.filter((existing) => existing !== scope);
141 } else {
142 newScopes.push(scope);
143 }
144
145 onScopesChanged(newScopes, isolated);
146 };
147
148 const ActiveScopeGroupIcon = scopeGroupIconMap[scopeGroup];
149
150 return (
151 <>
152 <Sheet open={isSheetOpen} onOpenChange={setSheetOpen}>
153 <SheetContent>
154 <SheetHeader>
155 <SheetTitle>Choose wallet permissions</SheetTitle>
156 </SheetHeader>
157 <div className="flex flex-col gap-4 px-6">
158 {scopeGroups.map((sg, index) => {
159 const ScopeGroupIcon = scopeGroupIconMap[sg];
160 return (
161 <button
162 type="button"
163 key={index}
164 className={cn(
165 "flex gap-3 items-center rounded-lg border-2 cursor-pointer p-4",
166 scopeGroup == sg ? "border-primary" : "border-muted"
167 )}
168 onClick={() => {
169 handleScopeGroupChange(sg);
170 setSheetOpen(false);
171 }}
172 >
173 <ScopeGroupIcon className="shrink-0 size-5 text-muted-foreground" />
174 <div className="flex flex-col gap-0.5 text-left">
175 <p className="text-sm font-medium">{scopeGroupTitle[sg]}</p>
176 <span className="text-xs text-muted-foreground">
177 {scopeGroupDescriptions[sg]}
178 </span>
179 </div>
180 </button>
181 );
182 })}
183 </div>
184 <SheetFooter>
185 <Button type="submit">Save changes</Button>
186 <SheetClose asChild>
187 <Button variant="outline">Close</Button>
188 </SheetClose>
189 </SheetFooter>
190 </SheetContent>
191 </Sheet>
192 <button
193 type="button"
194 className="flex items-center gap-2 rounded-lg border cursor-pointer p-4 w-full"
195 onClick={() => {
196 setSheetOpen(true);
197 }}
198 >
199 <ActiveScopeGroupIcon className="shrink-0 size-5 text-muted-foreground" />
200 <div className="flex flex-col gap-0.5 text-left">
201 <p className="text-sm font-medium">{scopeGroupTitle[scopeGroup]}</p>
202 <span className="text-xs text-muted-foreground">
203 {scopeGroupDescriptions[scopeGroup]}
204 </span>
205 </div>
206 <ChevronsUpDownIcon className="ml-auto shrink-0 size-4 text-muted-foreground" />
207 </button>
208
209 {scopeGroup == "custom" && (
210 <div className="mb-2">
211 <p className="font-medium text-sm mt-4">Isolation</p>
212 <div className="flex items-center mt-2">
213 <Checkbox
214 id="isolated"
215 className="mr-2"
216 onCheckedChange={() => onScopesChanged(scopes, !isolated)}
217 checked={isolated}
218 />
219 <Label htmlFor="isolated" className="cursor-pointer">
220 Isolate this app's balance and transactions
221 </Label>
222 </div>
223 <p className="font-medium text-sm mt-4">Authorize the app to:</p>
224 <ul className="flex flex-col w-full mt-2">
225 {capabilities.scopes.map((scope, index) => {
226 return (
227 <li
228 key={index}
229 className={cn(
230 "w-full",
231 scope == "pay_invoice" ? "order-last" : ""
232 )}
233 >
234 <div className="flex items-center mb-2">
235 <Checkbox
236 id={scope}
237 className="mr-2"
238 onCheckedChange={() => handleScopeChange(scope)}
239 checked={scopes.includes(scope)}
240 />
241 <Label htmlFor={scope} className="cursor-pointer">
242 {scopeDescriptions[scope]}
243 </Label>
244 </div>
245 </li>
246 );
247 })}
248 </ul>
249 </div>
250 )}
251 </>
252 );
253 };
254
255 export default Scopes;
256