A taken address on PostgreSQL fell through to a generic error, because the store looked for SQLite's "UNIQUE". It checks code 23505 too. The done page tells someone whose address had an account to sign in or ask for a reset, without saying which case it was; other failures give a logged reference. Refs #2 (item 6). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
224 lines
11 KiB
HTML
224 lines
11 KiB
HTML
{{define "signup"}}<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Sign up · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap narrow">
|
|
<header><h1>Create an account</h1>
|
|
<p class="sub">An operator approves it before it can be used.</p></header>
|
|
|
|
{{if .Done}}
|
|
<p>Thank you. Your account is waiting for approval.</p>
|
|
<p class="note">You will not get an email from this gateway — it does not send any.
|
|
Tell whoever you asked for access that you have signed up.</p>
|
|
<p><a href="/login">Sign in</a> once you have been approved.</p>
|
|
{{/* The same page whether or not the address already had an account, so
|
|
it says what to do in either case without saying which this was. */}}
|
|
<p class="note">If this address already had an account, nothing new was made:
|
|
<a href="/login">sign in</a> with it, or, if you have forgotten its password, ask
|
|
an operator to reset it.</p>
|
|
{{else}}
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<form method="post" autocomplete="on">
|
|
<label>Email<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
|
<label>Name<input type="text" name="name" value="{{.Name}}" autocomplete="name"></label>
|
|
<label>Password<input type="password" name="password" required minlength="{{.MinPassword}}"
|
|
autocomplete="new-password"></label>
|
|
<p class="note">At least {{.MinPassword}} characters. Length is the only rule —
|
|
a long phrase beats a short one with punctuation in it.</p>
|
|
<button type="submit">Sign up</button>
|
|
</form>
|
|
<p class="note">Already have an account? <a href="/login">Sign in</a>.</p>
|
|
{{end}}
|
|
<footer><span>llmgw {{.Version}}</span><span><a href="/">about this gateway</a></span></footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{define "login"}}<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Sign in · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap narrow">
|
|
<header><h1>Sign in</h1></header>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<form method="post" autocomplete="on">
|
|
<label>Email<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
|
<label>Password<input type="password" name="password" required autocomplete="current-password"></label>
|
|
<button type="submit">Sign in</button>
|
|
</form>
|
|
{{if .SignupOpen}}<p class="note">No account? <a href="/signup">Sign up</a>.</p>{{end}}
|
|
<footer><span>llmgw {{.Version}}</span><span><a href="/">about this gateway</a></span></footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{define "profile"}}<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Account · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap">
|
|
<header>
|
|
<h1>Your account</h1>
|
|
<p class="sub">{{.User.Email}}{{if ne .User.Name .User.Email}} ({{.User.Name}}){{end}}</p>
|
|
{{template "nav" .Nav}}
|
|
</header>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
|
|
{{if .NewKey}}
|
|
<h2>Your new key</h2>
|
|
<p>This is shown once. Nothing can produce it again — only its hash is kept.</p>
|
|
<pre>{{.NewKey}}</pre>
|
|
<p>Put it in a password manager now.</p>
|
|
<pre>curl {{.BaseURL}}/v1/chat/completions \
|
|
-H "Authorization: Bearer {{.NewKey}}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"model": "{{.ExampleModel}}", "messages": [{"role":"user","content":"hello"}]}'</pre>
|
|
{{end}}
|
|
|
|
{{with .Connect}}
|
|
<section class="connect-result" id="connected">
|
|
<h2>{{.ToolName}} is ready to connect</h2>
|
|
<p>A key was made for it, <strong>shown this once</strong> — it is in everything below. Nothing can produce it again.</p>
|
|
{{if .Command}}
|
|
<h3>One command</h3>
|
|
<p class="note">Writes the file below{{if eq .Tool "codex"}} into your Codex configuration{{end}}, keeping a copy of anything it replaces.</p>
|
|
<div class="copyable"><pre>{{.Command}}</pre><button type="button" class="link copy">copy</button></div>
|
|
{{end}}
|
|
{{range .Files}}
|
|
<h3>Or by hand: <code>{{.Path}}</code></h3>
|
|
<div class="copyable"><pre>{{.Body}}</pre><button type="button" class="link copy">copy</button>
|
|
<button type="button" class="link download-file" data-name="{{.Name}}">download</button></div>
|
|
{{end}}
|
|
{{if .Fields}}
|
|
<h3>Settings to fill in</h3>
|
|
<table class="rows compact narrow-table connect-fields">{{range .Fields}}
|
|
<tr><th>{{.Label}}</th><td><code>{{.Value}}</code></td><td><button type="button" class="link copy" data-value="{{.Value}}">copy</button></td></tr>{{end}}
|
|
</table>
|
|
{{end}}
|
|
<h3>Then</h3>
|
|
<p>{{if eq .Tool "codex"}}<code>{{.Run}}</code>{{else}}{{.Run}}{{end}}</p>
|
|
{{range .Notes}}<p class="note">{{.}}</p>{{end}}
|
|
</section>
|
|
{{end}}
|
|
|
|
<h2>Connect your editor</h2>
|
|
<p class="note">A key of its own for the tool, and its configuration with the key, this gateway and a model
|
|
already in it. Revoke the key below to disconnect it.</p>
|
|
<form method="post" action="/profile/connect#connected" class="connect-form">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<div class="field-row">
|
|
<label>Tool
|
|
<select name="tool">{{range .Tools}}<option value="{{.Key}}">{{.Name}}</option>{{end}}</select></label>
|
|
<label>Model
|
|
{{if .Models}}<select name="model">{{range .Models}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
|
{{else}}<input type="text" name="model" placeholder="a model the gateway serves" required>{{end}}</label>
|
|
{{if .Contexts}}<label>Spends against
|
|
<select name="context">{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select></label>{{end}}
|
|
</div>
|
|
<button type="submit">Make a key and the configuration</button>
|
|
</form>
|
|
<script>
|
|
// Copy and download for what the connect panel shows, done in the page: the
|
|
// key in it must not go back to the server to become a file.
|
|
document.addEventListener("click", (e) => {
|
|
const b = e.target.closest("button.copy, button.download-file");
|
|
if (!b) return;
|
|
const text = b.dataset.value || b.parentElement.querySelector("pre").textContent;
|
|
if (b.classList.contains("copy")) {
|
|
const done = () => { const was = b.textContent; b.textContent = "copied"; setTimeout(() => (b.textContent = was), 1500); };
|
|
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, () => window.prompt("Copy", text));
|
|
else window.prompt("Copy", text);
|
|
return;
|
|
}
|
|
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
|
const a = document.createElement("a");
|
|
a.href = url; a.download = b.dataset.name || "config";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
</script>
|
|
|
|
<h2>Create a key</h2>
|
|
<form method="post" action="/profile/keys" class="narrow">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>What is it for?<input type="text" name="name" placeholder="laptop, ci, notebook" required></label>
|
|
{{if .Contexts}}
|
|
<label>Spends against
|
|
<select name="context">
|
|
{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
|
|
</select></label>
|
|
<button type="submit">Create</button>
|
|
{{else}}
|
|
<p class="note">You are in no project yet, and personal keys are off here.
|
|
Ask an administrator to add you to one.</p>
|
|
{{end}}
|
|
</form>
|
|
|
|
<h2>Keys</h2>
|
|
{{if .Keys}}
|
|
<table class="rows"><tr><th>Name</th><th>Key</th><th>Spends against</th><th>Created</th><th>Last used</th><th>State</th><th></th></tr>
|
|
{{range .Keys}}<tr>
|
|
<td>{{.Name}}</td>
|
|
<td><code>{{.Prefix}}</code></td>
|
|
<td>{{if .ProjectID}}{{or (index $.Where .ProjectID) "a project you have left"}}{{else}}Personal{{end}}</td>
|
|
<td>{{date .CreatedAt}}</td>
|
|
<td>{{ago .LastUsedAt}}</td>
|
|
<td class="state state-{{state .}}">{{state .}}</td>
|
|
<td>{{if eq (state .) "active"}}
|
|
<form method="post" action="/profile/keys/{{.ID}}/revoke">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">Revoke</button>
|
|
</form>
|
|
{{end}}</td>
|
|
</tr>{{end}}</table>
|
|
<p class="note">Revoking takes effect within the gateway's cache window, up to a
|
|
minute. To rotate: create the new key, switch over, wait for "last used" on the
|
|
old one to stop moving, then revoke it.</p>
|
|
{{else}}<p class="empty">You have no keys yet.</p>{{end}}
|
|
|
|
<h2>Where you are</h2>
|
|
{{if .Memberships}}
|
|
<p>{{range $i, $m := .Memberships}}{{if $i}} · {{end}}{{$m.Path}} <span class="opt">{{$m.Scope}}</span>{{end}}</p>
|
|
{{else}}<p class="empty">You are not a member of any organization, team or project.</p>{{end}}
|
|
|
|
<h2>Your use and limits</h2>
|
|
{{with .Own}}
|
|
<div class="tiles own-use">
|
|
<div class="tile"><div class="v">{{compact .Requests}}</div><div class="k">requests · 30 days</div></div>
|
|
<div class="tile"><div class="v">{{compact .Tokens}}</div><div class="k">tokens · 30 days</div></div>
|
|
<div class="tile"><div class="v">{{.Spend}}</div><div class="k">spend · 30 days</div></div>
|
|
</div>
|
|
{{with .Chart}}<div class="chart-wrap"><h3 class="chart-title">Tokens per day</h3>{{.}}</div>{{end}}
|
|
{{end}}
|
|
{{range .LimitsBy}}
|
|
<h3 class="limits-for">{{.Label}}{{with .Use}} <span class="opt">— {{compact .Requests}} requests, {{compact .Tokens}} tokens in 30 days</span>{{end}}</h3>
|
|
{{range .Budgets}}
|
|
<div class="budget{{if .Over}} over{{else if .Near}} near{{end}}">
|
|
<div class="budget-head"><span>{{.Label}}</span><span><b>{{.Used}}</b> of {{.Budget}} {{.Period}}</span></div>
|
|
<div class="budget-bar"><span style="width: {{.Percent}}%"></span></div>
|
|
</div>
|
|
{{end}}
|
|
{{if .Rows}}
|
|
<table class="rows compact narrow-table"><tr><th>Limit</th><th class="num">Value</th></tr>
|
|
{{range .Rows}}<tr><td>{{.Name}}</td><td class="num">{{.Value}}</td></tr>{{end}}</table>
|
|
{{else}}<p class="empty">No limits here.</p>{{end}}
|
|
{{else}}<p class="empty">Nowhere to spend yet.</p>{{end}}
|
|
<p class="note">Everything that applies, from the organization down to you: the
|
|
strictest value wins.</p>
|
|
|
|
<h2>Password</h2>
|
|
{{if .PasswordChanged}}<p>Your password was changed. Any other browser you were
|
|
signed in on has been signed out.</p>{{end}}
|
|
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
|
|
<form method="post" action="/profile/password" autocomplete="on" class="narrow">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>Current password<input type="password" name="current" required
|
|
autocomplete="current-password"></label>
|
|
<label>New password<input type="password" name="new" required
|
|
minlength="{{.MinPassword}}" autocomplete="new-password"></label>
|
|
<button type="submit">Change password</button>
|
|
</form>
|
|
<p class="note">Changing it signs out every other browser, which is the point if
|
|
you are changing it because something went wrong.</p>
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
|
</div></body></html>{{end}}
|