| 1 | using AgentForge.Abstractions; |
| 2 | using AgentForge.Data.Entities; |
| 3 | using AgentForge.Plugin.View; |
| 4 | using AgentForge.Services; |
| 5 | |
| 6 | namespace AgentForge.Plugin.OAuth.Web.Pages; |
| 7 | |
| 8 | internal static class ForgeAuthPages |
| 9 | { |
| 10 | internal static string RenderLogin( |
| 11 | ForgeOAuthProviderRegistry providers, |
| 12 | IForgeOAuthLoginContributorCatalog oauthUi, |
| 13 | ForgeViewViewerContext viewer, |
| 14 | string? error) |
| 15 | { |
| 16 | var errorHtml = string.IsNullOrWhiteSpace(error) |
| 17 | ? "" |
| 18 | : ForgeHtml.P("error", ForgeHtml.Text(error switch |
| 19 | { |
| 20 | "invalid_token" => "Token invalid or revoked.", |
| 21 | "missing_token" => "Token required.", |
| 22 | "access_denied" => "Sign-in not allowed on this forge host. Ask an org admin for access.", |
| 23 | _ => "Sign-in failed.", |
| 24 | })); |
| 25 | |
| 26 | var providerLines = new List<string>(); |
| 27 | foreach (var provider in providers.ListProviders()) |
| 28 | { |
| 29 | providerLines.Add(ForgeHtml.P("meta", |
| 30 | ForgeHtml.Text($"{provider.DisplayName}: run "), |
| 31 | ForgeHtml.Code($"forge auth login --oauth {provider.Id}"))); |
| 32 | } |
| 33 | |
| 34 | if (providers.ListProviders().Count == 0) |
| 35 | { |
| 36 | providerLines.Add(ForgeHtml.P("meta", |
| 37 | ForgeHtml.Text("No OAuth providers configured. Set FORGE_GITHUB_CLIENT_ID/SECRET or use device login."))); |
| 38 | } |
| 39 | |
| 40 | var signOut = viewer.IsGuest |
| 41 | ? "" |
| 42 | : ForgeHtml.Form( |
| 43 | "post", |
| 44 | "/view/auth/logout", |
| 45 | "field field-top", |
| 46 | ForgeHtml.Button("submit", "Sign out", "btn-reject")); |
| 47 | |
| 48 | return ForgeHtml.Fragment( |
| 49 | ForgeHtml.H1("Sign in to Forge"), |
| 50 | ForgeHtml.P("meta", |
| 51 | ForgeHtml.Text("Paste an API token from "), |
| 52 | ForgeHtml.Code("forge auth login"), |
| 53 | ForgeHtml.Text(" to use the web view as yourself.")), |
| 54 | errorHtml, |
| 55 | RenderOAuthButtons(providers, oauthUi), |
| 56 | ForgeHtml.Fragment([.. providerLines]), |
| 57 | RenderTokenForm(), |
| 58 | ForgeHtml.P("meta", ForgeHtml.A("/view/auth/device", "Device login (bootstrap)")), |
| 59 | signOut); |
| 60 | } |
| 61 | |
| 62 | private static string RenderTokenForm() => |
| 63 | ForgeHtml.Form( |
| 64 | "post", |
| 65 | "/view/auth/session", |
| 66 | "field", |
| 67 | ForgeHtml.Label("token", "API token"), |
| 68 | ForgeHtml.Input(new ForgeInputSpec("token", Type: "password", Autocomplete: "off", Required: true)), |
| 69 | ForgeHtml.FieldSubmit(null, ForgeHtml.Button("submit", "Sign in"))); |
| 70 | |
| 71 | private static string RenderOAuthButtons( |
| 72 | ForgeOAuthProviderRegistry providers, |
| 73 | IForgeOAuthLoginContributorCatalog oauthUi) |
| 74 | { |
| 75 | var configured = providers.ListProviders() |
| 76 | .Select(p => p.Id) |
| 77 | .ToHashSet(StringComparer.OrdinalIgnoreCase); |
| 78 | |
| 79 | var controls = oauthUi.Contributors |
| 80 | .Where(c => configured.Contains(c.ProviderId)) |
| 81 | .Select(c => c.RenderSignInControl(Uri.EscapeDataString("/view"))) |
| 82 | .Where(html => !string.IsNullOrWhiteSpace(html)) |
| 83 | .ToList(); |
| 84 | |
| 85 | if (controls.Count == 0) |
| 86 | return ""; |
| 87 | |
| 88 | return ForgeHtml.Fragment( |
| 89 | ForgeHtml.Div("forge-oauth-row", string.Concat(controls)), |
| 90 | ForgeHtml.P("meta", ForgeHtml.Text("Browser OAuth sets a session cookie — no token paste."))); |
| 91 | } |
| 92 | |
| 93 | internal static string RenderDeviceLogin(string userCode, ForgeDeviceLoginEntity? pending) |
| 94 | { |
| 95 | return ForgeHtml.Fragment( |
| 96 | ForgeHtml.H1("Device login"), |
| 97 | RenderDeviceSteps(), |
| 98 | RenderDeviceLookupForm(userCode), |
| 99 | RenderDeviceNotFound(userCode, pending), |
| 100 | pending is null ? "" : RenderDeviceApprove(pending)); |
| 101 | } |
| 102 | |
| 103 | private static string RenderDeviceSteps() => |
| 104 | ForgeHtml.Ol("steps meta", |
| 105 | ForgeHtml.Li(null, |
| 106 | ForgeHtml.Text("Run "), |
| 107 | ForgeHtml.Code("forge auth login"), |
| 108 | ForgeHtml.Text(" (or CIDE "), |
| 109 | ForgeHtml.Code("forge_lens.connect"), |
| 110 | ForgeHtml.Text(") in a terminal.")), |
| 111 | ForgeHtml.Li(null, |
| 112 | ForgeHtml.Text("Copy the "), |
| 113 | ForgeHtml.Strong("user code"), |
| 114 | ForgeHtml.Text(" it prints (e.g. "), |
| 115 | ForgeHtml.Code("ABCD-EFGH"), |
| 116 | ForgeHtml.Text(") or click the verification link.")), |
| 117 | ForgeHtml.Li(null, ForgeHtml.Text("Paste the code below and approve with your bootstrap token."))); |
| 118 | |
| 119 | private static string RenderDeviceLookupForm(string userCode) => |
| 120 | ForgeHtml.Form( |
| 121 | "get", |
| 122 | "/view/auth/device", |
| 123 | "field", |
| 124 | ForgeHtml.Label("code", "User code"), |
| 125 | ForgeHtml.Input(new ForgeInputSpec( |
| 126 | "code", |
| 127 | Id: "code", |
| 128 | Placeholder: "ABCD-EFGH", |
| 129 | Autocomplete: "one-time-code", |
| 130 | InputMode: "text", |
| 131 | Required: true, |
| 132 | Value: userCode.Length > 0 ? userCode : null)), |
| 133 | ForgeHtml.FieldSubmit(null, ForgeHtml.Button("submit", "Look up request"))); |
| 134 | |
| 135 | private static string RenderDeviceNotFound(string userCode, ForgeDeviceLoginEntity? pending) => |
| 136 | userCode.Length > 0 && pending is null |
| 137 | ? ForgeHtml.P("error", |
| 138 | ForgeHtml.Text("No pending login for "), |
| 139 | ForgeHtml.Code(userCode), |
| 140 | ForgeHtml.Text(". Run "), |
| 141 | ForgeHtml.Code("forge auth login"), |
| 142 | ForgeHtml.Text(" again — codes expire.")) |
| 143 | : ""; |
| 144 | |
| 145 | private static string RenderDeviceApprove(ForgeDeviceLoginEntity pending) => |
| 146 | ForgeHtml.Fragment( |
| 147 | ForgeHtml.H2("Approve this request"), |
| 148 | ForgeHtml.P("meta", |
| 149 | ForgeHtml.Text("Client "), |
| 150 | ForgeHtml.Strong(pending.ClientName), |
| 151 | ForgeHtml.Text(" wants API access.")), |
| 152 | ForgeHtml.FormWithId( |
| 153 | "approve-form", |
| 154 | "post", |
| 155 | "#", |
| 156 | "field", |
| 157 | ForgeHtml.Label("bootstrap", "Bootstrap or write-scoped API token"), |
| 158 | ForgeHtml.Input(new ForgeInputSpec("bootstrap", Type: "password", Autocomplete: "off", Required: true)), |
| 159 | ForgeHtml.FieldSubmit(null, ForgeHtml.Button("submit", "Approve login"))), |
| 160 | ForgeHtml.PWithId("status", "meta", ""), |
| 161 | ForgeAuthDeviceApproveScript.Render(ForgeViewPresenter.JsonEncode(pending.UserCode)), |
| 162 | ForgeHtml.P("meta", |
| 163 | ForgeHtml.Text("Terminal alternative: "), |
| 164 | ForgeHtml.Code($"forge auth approve {pending.UserCode}"))); |
| 165 | } |
| 166 | |