fix: inline GitHub OAuth device flow in Connect GitHub modal (#136)
* fix: inline GitHub OAuth device flow in Connect GitHub modal Previously, clicking "Connect GitHub repo" without a token would redirect to Settings, forcing the user to authenticate there and then navigate back. Now the device flow runs directly inside the GitHubVaultModal, showing the user code and opening the browser inline. - Extract GitHubDeviceFlow component from SettingsPanel (shared by both) - Add onGitHubConnected prop to GitHubVaultModal for inline auth - Update mock defaults to no-token state for testable device flow - Add tests for inline device flow in modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add GitHub OAuth fix wireframes Three frames showing the fixed modal states: 1. Login button (inline device flow start) 2. Device code waiting (code + URL + spinner) 3. Error/expired state with retry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,302 +2,68 @@
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_oauth_frame",
|
||||
"name": "GitHub OAuth — Device Code Waiting",
|
||||
"id": "gofix01",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 520,
|
||||
"height": "fit_content(600)",
|
||||
"fill": "#ffffff",
|
||||
"name": "GitHub OAuth Fix — Modal with inline device flow",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"stroke": "#e5e5e5",
|
||||
"strokeWidth": 1,
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_header",
|
||||
"id": "gofix01-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"layout": "horizontal",
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"gap": 0,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_title",
|
||||
"content": "Settings",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1a1a1a"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_close",
|
||||
"content": "✕",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fill": "#888888"
|
||||
}
|
||||
{ "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "gh_div1",
|
||||
"type": "frame",
|
||||
"id": "gofix01-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#e5e5e5"
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_body",
|
||||
"name": "Body",
|
||||
"id": "gofix01-body",
|
||||
"name": "DeviceFlowLogin",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"padding": 24,
|
||||
"gap": 20,
|
||||
"gap": 16,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_section_header",
|
||||
"name": "GitHub Section Header",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_section_title",
|
||||
"content": "GitHub",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1a1a1a"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_section_desc",
|
||||
"content": "Connect your GitHub account to clone and sync vaults.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888",
|
||||
"lineHeight": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_waiting_card",
|
||||
"name": "Device Code Card",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"padding": [16, 20],
|
||||
"gap": 10,
|
||||
"alignItems": "center",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"stroke": "#e5e5e5",
|
||||
"strokeWidth": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_prompt_text",
|
||||
"content": "Enter this code on GitHub:",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_code_row",
|
||||
"name": "Code + Copy",
|
||||
"layout": "horizontal",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_user_code",
|
||||
"content": "ABCD-1234",
|
||||
"fontFamily": "JetBrains Mono, monospace",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700",
|
||||
"letterSpacing": 4,
|
||||
"fill": "#1a1a1a"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_copy_btn",
|
||||
"name": "Copy Button",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_copy_icon",
|
||||
"content": "⧉",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fill": "#888888"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_url_link",
|
||||
"content": "https://github.com/login/device",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888",
|
||||
"textDecoration": "underline"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_spinner_row",
|
||||
"name": "Waiting Spinner",
|
||||
"layout": "horizontal",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_spinner_icon",
|
||||
"content": "◌",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fill": "#888888"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_spinner_text",
|
||||
"content": "Waiting for authorization...",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_cancel_row",
|
||||
"name": "Cancel Row",
|
||||
"width": "fill_container",
|
||||
"id": "gofix01-login-btn",
|
||||
"name": "LoginButton",
|
||||
"width": 200,
|
||||
"height": 36,
|
||||
"fill": "$--foreground",
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_cancel_btn",
|
||||
"name": "Cancel Button",
|
||||
"layout": "horizontal",
|
||||
"padding": [6, 12],
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"stroke": "#e5e5e5",
|
||||
"strokeWidth": 1,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_cancel_text",
|
||||
"content": "Cancel",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "gh_div2",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#e5e5e5"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_footer",
|
||||
"name": "Footer",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"layout": "horizontal",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_shortcut",
|
||||
"content": "⌘, to open settings",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fill": "#888888"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_footer_buttons",
|
||||
"name": "Footer Buttons",
|
||||
"layout": "horizontal",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_cancel_footer",
|
||||
"name": "Cancel",
|
||||
"padding": [6, 16],
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"stroke": "#e5e5e5",
|
||||
"strokeWidth": 1,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_cancel_f_text",
|
||||
"content": "Cancel",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fill": "#1a1a1a"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_save_btn",
|
||||
"name": "Save",
|
||||
"padding": [6, 16],
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"fill": "#3b82f6",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_save_text",
|
||||
"content": "Save",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fill": "#ffffff"
|
||||
}
|
||||
]
|
||||
}
|
||||
{ "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
|
||||
{ "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -306,122 +72,181 @@
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_error_frame",
|
||||
"name": "GitHub OAuth — Error with Retry",
|
||||
"x": 620,
|
||||
"id": "gofix02",
|
||||
"x": 660,
|
||||
"y": 0,
|
||||
"width": 520,
|
||||
"height": "fit_content(300)",
|
||||
"fill": "#ffffff",
|
||||
"name": "GitHub OAuth Fix — Device code waiting state",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"padding": 24,
|
||||
"gap": 16,
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"stroke": "#e5e5e5",
|
||||
"strokeWidth": 1,
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_err_header",
|
||||
"id": "gofix02-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-body",
|
||||
"name": "DeviceCodeCard",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_err_title",
|
||||
"content": "GitHub",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1a1a1a"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_err_desc",
|
||||
"content": "Connect your GitHub account to clone and sync vaults.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888",
|
||||
"lineHeight": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_login_btn",
|
||||
"name": "Login Button",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "#1a1a1a",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"layout": "horizontal",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_login_icon",
|
||||
"content": "⬤",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fill": "#ffffff"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_login_text",
|
||||
"content": "Login with GitHub",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"fill": "#ffffff"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_error_row",
|
||||
"name": "Error + Retry",
|
||||
"layout": "horizontal",
|
||||
"gap": 8,
|
||||
"gap": 12,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_error_msg",
|
||||
"content": "Authorization expired. Please try again.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#e03e3e"
|
||||
"type": "frame",
|
||||
"id": "gofix02-card",
|
||||
"name": "CodeCard",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [16, 24],
|
||||
"cornerRadius": 8,
|
||||
"alignItems": "center",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-code-row",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-spinner-row",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gh_retry_btn",
|
||||
"name": "Retry Button",
|
||||
"layout": "horizontal",
|
||||
"gap": 4,
|
||||
"id": "gofix02-cancel-btn",
|
||||
"name": "CancelButton",
|
||||
"width": 80,
|
||||
"height": 32,
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03",
|
||||
"x": 1320,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Expired/Error state with retry",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-body",
|
||||
"name": "ErrorState",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-login-btn",
|
||||
"name": "LoginButton",
|
||||
"width": 200,
|
||||
"height": 36,
|
||||
"fill": "$--foreground",
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
|
||||
{ "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-error-row",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_retry_icon",
|
||||
"content": "↻",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#e03e3e"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gh_retry_text",
|
||||
"content": "Retry",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fill": "#e03e3e"
|
||||
}
|
||||
{ "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" },
|
||||
{ "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -430,4 +255,4 @@
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +389,7 @@ function App() {
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
226
src/components/GitHubDeviceFlow.tsx
Normal file
226
src/components/GitHubDeviceFlow.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { GithubLogo, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import type { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
type OAuthStatus = 'idle' | 'waiting' | 'error'
|
||||
|
||||
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
|
||||
function processPollResult(
|
||||
result: DeviceFlowPollResult,
|
||||
callbacks: {
|
||||
onComplete: (token: string) => Promise<void>
|
||||
onExpired: () => void
|
||||
onError: (msg: string) => void
|
||||
},
|
||||
): 'done' | 'continue' {
|
||||
if (result.status === 'complete' && result.access_token) {
|
||||
callbacks.onComplete(result.access_token)
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
callbacks.onExpired()
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
callbacks.onError(result.error ?? 'Authorization failed.')
|
||||
return 'done'
|
||||
}
|
||||
return 'continue'
|
||||
}
|
||||
|
||||
interface GitHubDeviceFlowProps {
|
||||
onConnected: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) {
|
||||
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(null)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollingRef.current = false
|
||||
deviceCodeRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { pollingRef.current = false }
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setOauthStatus('waiting')
|
||||
setErrorMessage(null)
|
||||
setUserCode(null)
|
||||
|
||||
try {
|
||||
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
|
||||
setUserCode(flowStart.user_code)
|
||||
setVerificationUri(flowStart.verification_uri)
|
||||
deviceCodeRef.current = flowStart.device_code
|
||||
openExternalUrl(flowStart.verification_uri).catch(() => {})
|
||||
|
||||
pollingRef.current = true
|
||||
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
|
||||
|
||||
const pollLoop = async () => {
|
||||
while (pollingRef.current && deviceCodeRef.current) {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
if (!pollingRef.current) break
|
||||
|
||||
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('github_get_user', { token })
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
onConnected(token, user.login)
|
||||
},
|
||||
onExpired: () => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage('Authorization expired. Please try again.')
|
||||
},
|
||||
onError: (msg) => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
if (outcome === 'done') return
|
||||
}
|
||||
}
|
||||
|
||||
pollLoop().catch(err => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
|
||||
})
|
||||
} catch (err) {
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
|
||||
}
|
||||
}, [onConnected, stopPolling])
|
||||
|
||||
const resetOAuth = useCallback(() => {
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
setVerificationUri(null)
|
||||
setErrorMessage(null)
|
||||
}, [stopPolling])
|
||||
|
||||
if (oauthStatus === 'waiting' && userCode) {
|
||||
return <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
|
||||
}
|
||||
|
||||
function DeviceCodeView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyCode = useCallback(() => {
|
||||
navigator.clipboard.writeText(userCode).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {})
|
||||
}, [userCode])
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
|
||||
}, [verificationUri])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<button
|
||||
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={handleCopyCode}
|
||||
title="Copy code"
|
||||
data-testid="github-copy-code"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{verificationUri && (
|
||||
<button
|
||||
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenUrl}
|
||||
data-testid="github-open-url"
|
||||
>
|
||||
{verificationUri}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
|
||||
<span data-testid="github-error">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
|
||||
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
|
||||
onClick={onRetry}
|
||||
data-testid="github-retry"
|
||||
>
|
||||
<ArrowClockwise size={12} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
@@ -20,6 +23,7 @@ describe('GitHubVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
const onOpenSettings = vi.fn()
|
||||
const onGitHubConnected = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -38,7 +42,7 @@ describe('GitHubVaultModal', () => {
|
||||
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows connect prompt when no GitHub token', () => {
|
||||
it('shows settings fallback when no token and no onGitHubConnected', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
@@ -54,6 +58,37 @@ describe('GitHubVaultModal', () => {
|
||||
expect(onOpenSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows inline device flow when no token and onGitHubConnected is provided', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
expect(screen.getByTestId('github-login')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows device code after starting inline OAuth flow', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return { device_code: 'dc_modal', user_code: 'MODAL-5678', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }
|
||||
}
|
||||
if (cmd === 'github_device_flow_poll') {
|
||||
return { status: 'pending', access_token: null, error: 'authorization_pending' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows clone and create tabs when token is present', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { GithubRepo } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
@@ -18,6 +19,7 @@ interface GitHubVaultModalProps {
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
onOpenSettings: () => void
|
||||
onGitHubConnected?: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
|
||||
@@ -244,7 +246,7 @@ function CloningProgress({ repoName }: { repoName: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) {
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) {
|
||||
const [tab, setTab] = useState('clone')
|
||||
const [repos, setRepos] = useState<GithubRepo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -365,15 +367,21 @@ export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, on
|
||||
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
{onGitHubConnected ? (
|
||||
<GitHubDeviceFlow onConnected={onGitHubConnected} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import type { Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
@@ -74,8 +68,6 @@ function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProp
|
||||
|
||||
// --- GitHub OAuth Section ---
|
||||
|
||||
type OAuthStatus = 'idle' | 'waiting' | 'error'
|
||||
|
||||
interface GitHubSectionProps {
|
||||
githubUsername: string | null
|
||||
githubToken: string | null
|
||||
@@ -83,125 +75,14 @@ interface GitHubSectionProps {
|
||||
onDisconnect: () => void
|
||||
}
|
||||
|
||||
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
|
||||
function processPollResult(
|
||||
result: DeviceFlowPollResult,
|
||||
callbacks: {
|
||||
onComplete: (token: string) => Promise<void>
|
||||
onExpired: () => void
|
||||
onError: (msg: string) => void
|
||||
},
|
||||
): 'done' | 'continue' {
|
||||
if (result.status === 'complete' && result.access_token) {
|
||||
callbacks.onComplete(result.access_token)
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
callbacks.onExpired()
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
callbacks.onError(result.error ?? 'Authorization failed.')
|
||||
return 'done'
|
||||
}
|
||||
return 'continue' // status === 'pending'
|
||||
}
|
||||
|
||||
function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) {
|
||||
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(null)
|
||||
|
||||
const isConnected = !!githubToken && !!githubUsername
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollingRef.current = false
|
||||
deviceCodeRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { pollingRef.current = false }
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setOauthStatus('waiting')
|
||||
setErrorMessage(null)
|
||||
setUserCode(null)
|
||||
|
||||
try {
|
||||
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
|
||||
setUserCode(flowStart.user_code)
|
||||
setVerificationUri(flowStart.verification_uri)
|
||||
deviceCodeRef.current = flowStart.device_code
|
||||
openExternalUrl(flowStart.verification_uri).catch(() => {
|
||||
// Browser failed to open — URL is shown in UI so user can navigate manually
|
||||
})
|
||||
|
||||
pollingRef.current = true
|
||||
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
|
||||
|
||||
const pollLoop = async () => {
|
||||
while (pollingRef.current && deviceCodeRef.current) {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
if (!pollingRef.current) break
|
||||
|
||||
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('github_get_user', { token })
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
onConnected(token, user.login)
|
||||
},
|
||||
onExpired: () => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage('Authorization expired. Please try again.')
|
||||
},
|
||||
onError: (msg) => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
if (outcome === 'done') return
|
||||
}
|
||||
}
|
||||
|
||||
pollLoop().catch(err => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
|
||||
})
|
||||
} catch (err) {
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
|
||||
}
|
||||
}, [onConnected, stopPolling])
|
||||
|
||||
const resetOAuth = useCallback(() => {
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
setVerificationUri(null)
|
||||
setErrorMessage(null)
|
||||
}, [stopPolling])
|
||||
|
||||
if (isConnected && oauthStatus === 'idle') {
|
||||
return <GitHubConnectedRow username={githubUsername!} onDisconnect={() => { resetOAuth(); onDisconnect() }} />
|
||||
if (isConnected) {
|
||||
return <GitHubConnectedRow username={githubUsername!} onDisconnect={onDisconnect} />
|
||||
}
|
||||
|
||||
if (oauthStatus === 'waiting' && userCode) {
|
||||
return <GitHubWaitingView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <GitHubLoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
|
||||
return <GitHubDeviceFlow onConnected={onConnected} />
|
||||
}
|
||||
|
||||
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
|
||||
@@ -230,103 +111,6 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubWaitingView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyCode = useCallback(() => {
|
||||
navigator.clipboard.writeText(userCode).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {})
|
||||
}, [userCode])
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
|
||||
}, [verificationUri])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<button
|
||||
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={handleCopyCode}
|
||||
title="Copy code"
|
||||
data-testid="github-copy-code"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{verificationUri && (
|
||||
<button
|
||||
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenUrl}
|
||||
data-testid="github-open-url"
|
||||
>
|
||||
{verificationUri}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubLoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
|
||||
<span data-testid="github-error">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
|
||||
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
|
||||
onClick={onRetry}
|
||||
data-testid="github-retry"
|
||||
>
|
||||
<ArrowClockwise size={12} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Settings Panel ---
|
||||
|
||||
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
|
||||
|
||||
@@ -77,8 +77,8 @@ let mockSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: 'gho_mock_token_for_testing',
|
||||
github_username: 'lucaong',
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: 5,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user