| e93a978 | | | 1 | "use client"; |
| e93a978 | | | 2 | |
| e93a978 | | | 3 | import { Suspense, useState, useEffect } from "react"; |
| e93a978 | | | 4 | import { useSearchParams } from "next/navigation"; |
| e93a978 | | | 5 | import { startAuthentication } from "@simplewebauthn/browser"; |
| 818dc90 | | | 6 | import { GroveLogo } from "@/app/components/grove-logo"; |
| e93a978 | | | 7 | import { auth } from "@/lib/api"; |
| bf5fc33 | | | 8 | import { Skeleton } from "@/app/components/skeleton"; |
| e93a978 | | | 9 | |
| 7010ba9 | | | 10 | function DeviceCodeAuth({ code }: { code: string }) { |
| 7010ba9 | | | 11 | const [status, setStatus] = useState<"ready" | "confirm" | "loading" | "success" | "error">("ready"); |
| 7010ba9 | | | 12 | const [error, setError] = useState(""); |
| 7010ba9 | | | 13 | const [username, setUsername] = useState<string | null>(null); |
| 7010ba9 | | | 14 | |
| 7010ba9 | | | 15 | useEffect(() => { |
| 7010ba9 | | | 16 | const token = localStorage.getItem("grove_hub_token"); |
| 7010ba9 | | | 17 | if (token) { |
| 7010ba9 | | | 18 | try { |
| 7010ba9 | | | 19 | const payload = JSON.parse(atob(token.split(".")[1])); |
| 7010ba9 | | | 20 | setUsername(payload.username); |
| 7010ba9 | | | 21 | } catch { /* ignore */ } |
| 7010ba9 | | | 22 | setStatus("confirm"); |
| 7010ba9 | | | 23 | } |
| 7010ba9 | | | 24 | }, []); |
| 7010ba9 | | | 25 | |
| 7010ba9 | | | 26 | async function approveWithToken(sessionToken: string) { |
| 7010ba9 | | | 27 | setStatus("loading"); |
| 7010ba9 | | | 28 | try { |
| 1d40800 | | | 29 | const res = await fetch(`/api/auth/device-code/${code}/approve`, { |
| 7010ba9 | | | 30 | method: "POST", |
| 7010ba9 | | | 31 | headers: { |
| 7010ba9 | | | 32 | Authorization: `Bearer ${sessionToken}`, |
| 7010ba9 | | | 33 | }, |
| 7010ba9 | | | 34 | }); |
| 7010ba9 | | | 35 | if (!res.ok) { |
| 7010ba9 | | | 36 | const data = await res.json().catch(() => ({})); |
| 7010ba9 | | | 37 | throw new Error((data as any).error || "Failed to approve"); |
| 7010ba9 | | | 38 | } |
| 7010ba9 | | | 39 | setStatus("success"); |
| 7010ba9 | | | 40 | } catch (err: any) { |
| 7010ba9 | | | 41 | setStatus("error"); |
| 7010ba9 | | | 42 | setError(err.message || "Failed to approve device code"); |
| 7010ba9 | | | 43 | } |
| 7010ba9 | | | 44 | } |
| 7010ba9 | | | 45 | |
| 7010ba9 | | | 46 | async function handleAuthorize() { |
| 7010ba9 | | | 47 | const token = localStorage.getItem("grove_hub_token"); |
| 7010ba9 | | | 48 | if (token) await approveWithToken(token); |
| 7010ba9 | | | 49 | } |
| 7010ba9 | | | 50 | |
| 7010ba9 | | | 51 | async function handleLogin() { |
| 7010ba9 | | | 52 | setError(""); |
| 7010ba9 | | | 53 | setStatus("loading"); |
| 7010ba9 | | | 54 | try { |
| 7010ba9 | | | 55 | const { options } = await auth.loginBegin(); |
| 7010ba9 | | | 56 | const assertion = await startAuthentication({ optionsJSON: options }); |
| 7010ba9 | | | 57 | const result = await auth.loginComplete({ |
| 7010ba9 | | | 58 | response: assertion, |
| 7010ba9 | | | 59 | challenge: options.challenge, |
| 7010ba9 | | | 60 | }); |
| 7010ba9 | | | 61 | await approveWithToken(result.token); |
| 7010ba9 | | | 62 | } catch (err: any) { |
| 7010ba9 | | | 63 | setStatus("error"); |
| 7010ba9 | | | 64 | if (err.name === "NotAllowedError") { |
| 7010ba9 | | | 65 | setError("Passkey authentication was cancelled."); |
| 7010ba9 | | | 66 | } else if (err.message === "Unknown credential") { |
| 7010ba9 | | | 67 | setError("Passkey not recognized. Do you have an account?"); |
| 7010ba9 | | | 68 | } else { |
| 7010ba9 | | | 69 | setError(err.message || "Authentication failed"); |
| 7010ba9 | | | 70 | } |
| 7010ba9 | | | 71 | } |
| 7010ba9 | | | 72 | } |
| 7010ba9 | | | 73 | |
| 7010ba9 | | | 74 | return ( |
| 7010ba9 | | | 75 | <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4"> |
| 7010ba9 | | | 76 | <div |
| 7010ba9 | | | 77 | className="w-full max-w-sm p-8" |
| 7010ba9 | | | 78 | style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-subtle)" }} |
| 7010ba9 | | | 79 | > |
| 7010ba9 | | | 80 | <div className="flex justify-center mb-6"> |
| 7010ba9 | | | 81 | <GroveLogo size={40} /> |
| 7010ba9 | | | 82 | </div> |
| 7010ba9 | | | 83 | <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1> |
| 7010ba9 | | | 84 | |
| 7010ba9 | | | 85 | {status === "success" ? ( |
| 7010ba9 | | | 86 | <div className="text-center mt-6"> |
| 7010ba9 | | | 87 | <p className="text-sm" style={{ color: "var(--text-muted)" }}> |
| 7010ba9 | | | 88 | CLI authorized! You can close this tab and return to the terminal. |
| 7010ba9 | | | 89 | </p> |
| 7010ba9 | | | 90 | </div> |
| 7010ba9 | | | 91 | ) : status === "confirm" ? ( |
| 7010ba9 | | | 92 | <> |
| 7010ba9 | | | 93 | <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}> |
| 7010ba9 | | | 94 | Authorize the CLI as{" "} |
| 7010ba9 | | | 95 | <strong style={{ color: "var(--text-primary)" }}>{username}</strong>? |
| 7010ba9 | | | 96 | </p> |
| 7010ba9 | | | 97 | <div |
| 7010ba9 | | | 98 | className="text-center text-lg font-mono tracking-widest mb-6 py-3" |
| 7010ba9 | | | 99 | style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }} |
| 7010ba9 | | | 100 | > |
| 7010ba9 | | | 101 | {code} |
| 7010ba9 | | | 102 | </div> |
| 7010ba9 | | | 103 | {error && ( |
| 7010ba9 | | | 104 | <div |
| 7010ba9 | | | 105 | className="text-sm px-3 py-2 mb-4" |
| 7010ba9 | | | 106 | style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }} |
| 7010ba9 | | | 107 | > |
| 7010ba9 | | | 108 | {error} |
| 7010ba9 | | | 109 | </div> |
| 7010ba9 | | | 110 | )} |
| 7010ba9 | | | 111 | <button |
| 7010ba9 | | | 112 | onClick={handleAuthorize} |
| 7010ba9 | | | 113 | className="w-full text-sm py-2" |
| 7010ba9 | | | 114 | style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }} |
| 7010ba9 | | | 115 | > |
| 7010ba9 | | | 116 | Authorize CLI |
| 7010ba9 | | | 117 | </button> |
| 7010ba9 | | | 118 | </> |
| 7010ba9 | | | 119 | ) : ( |
| 7010ba9 | | | 120 | <> |
| 7010ba9 | | | 121 | <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}> |
| 7010ba9 | | | 122 | Confirm the code matches what's shown in your terminal. |
| 7010ba9 | | | 123 | </p> |
| 7010ba9 | | | 124 | <div |
| 7010ba9 | | | 125 | className="text-center text-lg font-mono tracking-widest mb-6 py-3" |
| 7010ba9 | | | 126 | style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }} |
| 7010ba9 | | | 127 | > |
| 7010ba9 | | | 128 | {code} |
| 7010ba9 | | | 129 | </div> |
| 7010ba9 | | | 130 | {error && ( |
| 7010ba9 | | | 131 | <div |
| 7010ba9 | | | 132 | className="text-sm px-3 py-2 mb-4" |
| 7010ba9 | | | 133 | style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }} |
| 7010ba9 | | | 134 | > |
| 7010ba9 | | | 135 | {error} |
| 7010ba9 | | | 136 | </div> |
| 7010ba9 | | | 137 | )} |
| 7010ba9 | | | 138 | <button |
| 7010ba9 | | | 139 | onClick={handleLogin} |
| 7010ba9 | | | 140 | disabled={status === "loading"} |
| 7010ba9 | | | 141 | className="w-full text-sm py-2" |
| 7010ba9 | | | 142 | style={{ |
| 7010ba9 | | | 143 | backgroundColor: "var(--accent)", |
| 7010ba9 | | | 144 | color: "var(--accent-text)", |
| 7010ba9 | | | 145 | opacity: status === "loading" ? 0.6 : 1, |
| 7010ba9 | | | 146 | cursor: status === "loading" ? "wait" : "pointer", |
| 7010ba9 | | | 147 | }} |
| 7010ba9 | | | 148 | > |
| 7010ba9 | | | 149 | {status === "loading" ? "Authenticating..." : "Sign in with Passkey"} |
| 7010ba9 | | | 150 | </button> |
| 7010ba9 | | | 151 | </> |
| 7010ba9 | | | 152 | )} |
| 7010ba9 | | | 153 | </div> |
| 7010ba9 | | | 154 | </div> |
| 7010ba9 | | | 155 | ); |
| 7010ba9 | | | 156 | } |
| 7010ba9 | | | 157 | |
| e93a978 | | | 158 | function CliAuthInner() { |
| e93a978 | | | 159 | const searchParams = useSearchParams(); |
| e93a978 | | | 160 | const callback = searchParams.get("callback"); |
| 7010ba9 | | | 161 | const code = searchParams.get("code"); |
| ff43545 | | | 162 | const [status, setStatus] = useState< |
| ff43545 | | | 163 | "ready" | "confirm" | "loading" | "success" | "error" |
| ff43545 | | | 164 | >("ready"); |
| e93a978 | | | 165 | const [error, setError] = useState(""); |
| ff43545 | | | 166 | const [username, setUsername] = useState<string | null>(null); |
| e93a978 | | | 167 | |
| 7010ba9 | | | 168 | // Device code flow |
| 7010ba9 | | | 169 | if (code) { |
| 7010ba9 | | | 170 | return <DeviceCodeAuth code={code} />; |
| 7010ba9 | | | 171 | } |
| 7010ba9 | | | 172 | |
| e93a978 | | | 173 | // Validate callback URL is localhost |
| e93a978 | | | 174 | const isValidCallback = (() => { |
| e93a978 | | | 175 | if (!callback) return false; |
| e93a978 | | | 176 | try { |
| e93a978 | | | 177 | const url = new URL(callback); |
| e93a978 | | | 178 | return url.hostname === "localhost" || url.hostname === "127.0.0.1"; |
| e93a978 | | | 179 | } catch { |
| e93a978 | | | 180 | return false; |
| e93a978 | | | 181 | } |
| e93a978 | | | 182 | })(); |
| e93a978 | | | 183 | |
| e93a978 | | | 184 | useEffect(() => { |
| e93a978 | | | 185 | if (!isValidCallback) return; |
| e93a978 | | | 186 | |
| ff43545 | | | 187 | // Check if user is already logged in |
| e93a978 | | | 188 | const token = localStorage.getItem("grove_hub_token"); |
| e93a978 | | | 189 | if (token) { |
| ff43545 | | | 190 | // Decode username from JWT for the confirmation screen |
| ff43545 | | | 191 | try { |
| ff43545 | | | 192 | const payload = JSON.parse(atob(token.split(".")[1])); |
| ff43545 | | | 193 | setUsername(payload.username); |
| ff43545 | | | 194 | } catch { |
| ff43545 | | | 195 | // ignore |
| ff43545 | | | 196 | } |
| ff43545 | | | 197 | setStatus("confirm"); |
| e93a978 | | | 198 | } |
| e93a978 | | | 199 | }, []); |
| e93a978 | | | 200 | |
| e93a978 | | | 201 | async function createPatAndRedirect(sessionToken: string) { |
| e93a978 | | | 202 | setStatus("loading"); |
| e93a978 | | | 203 | try { |
| e93a978 | | | 204 | const existingToken = localStorage.getItem("grove_hub_token"); |
| e93a978 | | | 205 | localStorage.setItem("grove_hub_token", sessionToken); |
| e93a978 | | | 206 | |
| e93a978 | | | 207 | const result = await auth.createToken({ |
| e93a978 | | | 208 | name: `CLI (${new Date().toLocaleDateString()})`, |
| e93a978 | | | 209 | expires_in: "1y", |
| e93a978 | | | 210 | }); |
| e93a978 | | | 211 | |
| e93a978 | | | 212 | if (existingToken) { |
| e93a978 | | | 213 | localStorage.setItem("grove_hub_token", existingToken); |
| e93a978 | | | 214 | } else { |
| e93a978 | | | 215 | localStorage.removeItem("grove_hub_token"); |
| e93a978 | | | 216 | } |
| e93a978 | | | 217 | |
| e93a978 | | | 218 | setStatus("success"); |
| e93a978 | | | 219 | window.location.href = `${callback}?token=${encodeURIComponent(result.token)}`; |
| e93a978 | | | 220 | } catch (err: any) { |
| e93a978 | | | 221 | setStatus("error"); |
| e93a978 | | | 222 | setError(err.message || "Failed to create access token"); |
| e93a978 | | | 223 | } |
| e93a978 | | | 224 | } |
| e93a978 | | | 225 | |
| ff43545 | | | 226 | async function handleAuthorize() { |
| ff43545 | | | 227 | const token = localStorage.getItem("grove_hub_token"); |
| ff43545 | | | 228 | if (token) { |
| ff43545 | | | 229 | await createPatAndRedirect(token); |
| ff43545 | | | 230 | } |
| ff43545 | | | 231 | } |
| ff43545 | | | 232 | |
| e93a978 | | | 233 | async function handleLogin() { |
| e93a978 | | | 234 | setError(""); |
| e93a978 | | | 235 | setStatus("loading"); |
| e93a978 | | | 236 | |
| e93a978 | | | 237 | try { |
| e93a978 | | | 238 | const { options } = await auth.loginBegin(); |
| e93a978 | | | 239 | const assertion = await startAuthentication({ optionsJSON: options }); |
| e93a978 | | | 240 | const result = await auth.loginComplete({ |
| e93a978 | | | 241 | response: assertion, |
| e93a978 | | | 242 | challenge: options.challenge, |
| e93a978 | | | 243 | }); |
| e93a978 | | | 244 | |
| e93a978 | | | 245 | await createPatAndRedirect(result.token); |
| e93a978 | | | 246 | } catch (err: any) { |
| e93a978 | | | 247 | setStatus("error"); |
| e93a978 | | | 248 | if (err.name === "NotAllowedError") { |
| e93a978 | | | 249 | setError("Passkey authentication was cancelled."); |
| e93a978 | | | 250 | } else if (err.message === "Unknown credential") { |
| e93a978 | | | 251 | setError("Passkey not recognized. Do you have an account?"); |
| e93a978 | | | 252 | } else { |
| e93a978 | | | 253 | setError(err.message || "Authentication failed"); |
| e93a978 | | | 254 | } |
| e93a978 | | | 255 | } |
| e93a978 | | | 256 | } |
| e93a978 | | | 257 | |
| e93a978 | | | 258 | if (!callback || !isValidCallback) { |
| e93a978 | | | 259 | return ( |
| e93a978 | | | 260 | <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4"> |
| e93a978 | | | 261 | <div |
| e93a978 | | | 262 | className="w-full max-w-sm p-8 text-center" |
| e93a978 | | | 263 | style={{ |
| e93a978 | | | 264 | backgroundColor: "var(--bg-card)", |
| e93a978 | | | 265 | border: "1px solid var(--border-subtle)", |
| e93a978 | | | 266 | }} |
| e93a978 | | | 267 | > |
| e93a978 | | | 268 | <h1 className="text-lg mb-2">Invalid Request</h1> |
| e93a978 | | | 269 | <p className="text-sm" style={{ color: "var(--text-muted)" }}> |
| e93a978 | | | 270 | This page is used by the Grove CLI to authenticate. |
| e93a978 | | | 271 | {!callback && " No callback URL provided."} |
| e93a978 | | | 272 | {callback && !isValidCallback && " Callback must be localhost."} |
| e93a978 | | | 273 | </p> |
| e93a978 | | | 274 | </div> |
| e93a978 | | | 275 | </div> |
| e93a978 | | | 276 | ); |
| e93a978 | | | 277 | } |
| e93a978 | | | 278 | |
| e93a978 | | | 279 | return ( |
| e93a978 | | | 280 | <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4"> |
| e93a978 | | | 281 | <div |
| e93a978 | | | 282 | className="w-full max-w-sm p-8" |
| e93a978 | | | 283 | style={{ |
| e93a978 | | | 284 | backgroundColor: "var(--bg-card)", |
| e93a978 | | | 285 | border: "1px solid var(--border-subtle)", |
| e93a978 | | | 286 | }} |
| e93a978 | | | 287 | > |
| e93a978 | | | 288 | {/* Logo */} |
| e93a978 | | | 289 | <div className="flex justify-center mb-6"> |
| 818dc90 | | | 290 | <GroveLogo size={40} /> |
| e93a978 | | | 291 | </div> |
| e93a978 | | | 292 | |
| e93a978 | | | 293 | <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1> |
| e93a978 | | | 294 | |
| e93a978 | | | 295 | {status === "success" ? ( |
| ff43545 | | | 296 | <div className="text-center mt-6"> |
| e93a978 | | | 297 | <p className="text-sm" style={{ color: "var(--text-muted)" }}> |
| e93a978 | | | 298 | Authenticated! Redirecting back to CLI... |
| e93a978 | | | 299 | </p> |
| e93a978 | | | 300 | </div> |
| ff43545 | | | 301 | ) : status === "confirm" ? ( |
| ff43545 | | | 302 | <> |
| ff43545 | | | 303 | <p |
| ff43545 | | | 304 | className="text-sm text-center mb-6" |
| ff43545 | | | 305 | style={{ color: "var(--text-muted)" }} |
| ff43545 | | | 306 | > |
| ff43545 | | | 307 | The Grove CLI is requesting access to your account |
| ff43545 | | | 308 | {username ? ( |
| ff43545 | | | 309 | <> |
| ff43545 | | | 310 | {" "}as <strong style={{ color: "var(--text-primary)" }}>{username}</strong> |
| ff43545 | | | 311 | </> |
| ff43545 | | | 312 | ) : ( |
| ff43545 | | | 313 | "" |
| ff43545 | | | 314 | )} |
| ff43545 | | | 315 | . This will create a personal access token. |
| ff43545 | | | 316 | </p> |
| ff43545 | | | 317 | |
| ff43545 | | | 318 | {error && ( |
| ff43545 | | | 319 | <div |
| ff43545 | | | 320 | className="text-sm px-3 py-2 mb-4" |
| ff43545 | | | 321 | style={{ |
| ff43545 | | | 322 | backgroundColor: "var(--error-bg)", |
| ff43545 | | | 323 | border: "1px solid var(--error-border)", |
| ff43545 | | | 324 | color: "var(--error-text)", |
| ff43545 | | | 325 | }} |
| ff43545 | | | 326 | > |
| ff43545 | | | 327 | {error} |
| ff43545 | | | 328 | </div> |
| ff43545 | | | 329 | )} |
| ff43545 | | | 330 | |
| ff43545 | | | 331 | <button |
| ff43545 | | | 332 | onClick={handleAuthorize} |
| ff43545 | | | 333 | disabled={status === "loading" as any} |
| ff43545 | | | 334 | className="w-full text-sm py-2" |
| ff43545 | | | 335 | style={{ |
| ff43545 | | | 336 | backgroundColor: "var(--accent)", |
| ff43545 | | | 337 | color: "var(--accent-text)", |
| ff43545 | | | 338 | }} |
| ff43545 | | | 339 | > |
| ff43545 | | | 340 | Authorize CLI |
| ff43545 | | | 341 | </button> |
| ff43545 | | | 342 | </> |
| e93a978 | | | 343 | ) : ( |
| e93a978 | | | 344 | <> |
| ff43545 | | | 345 | <p |
| ff43545 | | | 346 | className="text-sm text-center mb-6" |
| ff43545 | | | 347 | style={{ color: "var(--text-muted)" }} |
| ff43545 | | | 348 | > |
| ff43545 | | | 349 | Sign in with your passkey to authorize the CLI. |
| ff43545 | | | 350 | </p> |
| ff43545 | | | 351 | |
| e93a978 | | | 352 | {error && ( |
| e93a978 | | | 353 | <div |
| e93a978 | | | 354 | className="text-sm px-3 py-2 mb-4" |
| e93a978 | | | 355 | style={{ |
| e93a978 | | | 356 | backgroundColor: "var(--error-bg)", |
| e93a978 | | | 357 | border: "1px solid var(--error-border)", |
| e93a978 | | | 358 | color: "var(--error-text)", |
| e93a978 | | | 359 | }} |
| e93a978 | | | 360 | > |
| e93a978 | | | 361 | {error} |
| e93a978 | | | 362 | </div> |
| e93a978 | | | 363 | )} |
| e93a978 | | | 364 | |
| e93a978 | | | 365 | <button |
| e93a978 | | | 366 | onClick={handleLogin} |
| e93a978 | | | 367 | disabled={status === "loading"} |
| e93a978 | | | 368 | className="w-full text-sm py-2" |
| e93a978 | | | 369 | style={{ |
| e93a978 | | | 370 | backgroundColor: "var(--accent)", |
| e93a978 | | | 371 | color: "var(--accent-text)", |
| e93a978 | | | 372 | opacity: status === "loading" ? 0.6 : 1, |
| e93a978 | | | 373 | cursor: status === "loading" ? "wait" : "pointer", |
| e93a978 | | | 374 | }} |
| e93a978 | | | 375 | > |
| e93a978 | | | 376 | {status === "loading" |
| e93a978 | | | 377 | ? "Authenticating..." |
| e93a978 | | | 378 | : "Sign in with Passkey"} |
| e93a978 | | | 379 | </button> |
| e93a978 | | | 380 | </> |
| e93a978 | | | 381 | )} |
| e93a978 | | | 382 | </div> |
| e93a978 | | | 383 | </div> |
| e93a978 | | | 384 | ); |
| e93a978 | | | 385 | } |
| e93a978 | | | 386 | |
| e93a978 | | | 387 | export default function CliAuthPage() { |
| e93a978 | | | 388 | return ( |
| e93a978 | | | 389 | <Suspense |
| e93a978 | | | 390 | fallback={ |
| bf5fc33 | | | 391 | <div className="min-h-[calc(100vh-56px)] flex items-center justify-center"> |
| bf5fc33 | | | 392 | <div className="w-full max-w-sm space-y-4 p-6"> |
| bf5fc33 | | | 393 | <Skeleton width="40px" height="40px" className="mx-auto" /> |
| bf5fc33 | | | 394 | <Skeleton width="160px" height="1.25rem" className="mx-auto" /> |
| bf5fc33 | | | 395 | <Skeleton width="100%" height="2.5rem" /> |
| bf5fc33 | | | 396 | </div> |
| e93a978 | | | 397 | </div> |
| e93a978 | | | 398 | } |
| e93a978 | | | 399 | > |
| e93a978 | | | 400 | <CliAuthInner /> |
| e93a978 | | | 401 | </Suspense> |
| e93a978 | | | 402 | ); |
| e93a978 | | | 403 | } |