C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
15 KiB
HTML
289 lines
15 KiB
HTML
{{define "signup"}}<!doctype html>
|
|
<html lang="{{lang}}"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{t "signup.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap narrow">
|
|
<header><h1>{{t "signup.heading"}}</h1>
|
|
<p class="sub">{{t "signup.sub"}}</p></header>
|
|
|
|
{{if .Done}}
|
|
<p>{{t "signup.thanks"}}</p>
|
|
<p class="note">{{t "signup.no_email"}}</p>
|
|
<p>{{th "signup.sign_in_once_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">{{th "signup.already_had"}}</p>
|
|
{{else}}
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<form method="post" autocomplete="on">
|
|
<label>{{t "signup.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
|
<label>{{t "signup.name"}}<input type="text" name="name" value="{{.Name}}" autocomplete="name"></label>
|
|
<label>{{t "signup.password"}}<input type="password" name="password" required minlength="{{.MinPassword}}"
|
|
autocomplete="new-password"></label>
|
|
<p class="note">{{t "signup.password_rule" .MinPassword}}</p>
|
|
<button type="submit">{{t "signup.submit"}}</button>
|
|
</form>
|
|
<p class="note">{{th "signup.have_account"}}</p>
|
|
{{end}}
|
|
<footer><span>llmgw {{.Version}}</span><span><a href="/">{{t "common.about_gateway"}}</a></span>{{template "langs"}}</footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{define "login"}}<!doctype html>
|
|
<html lang="{{lang}}"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{t "login.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap narrow">
|
|
<header><h1>{{t "login.heading"}}</h1></header>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<form method="post" autocomplete="on">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
{{if .Directory}}<p class="sub">{{t "login.with_directory" .Directory}}</p>
|
|
<label>{{t "login.username"}}<input type="text" name="email" value="{{.Email}}" required autocomplete="username" autocapitalize="none" spellcheck="false"></label>
|
|
{{else}}<label>{{t "login.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>{{end}}
|
|
<label>{{t "login.password"}}<input type="password" name="password" required autocomplete="current-password"></label>
|
|
<button type="submit">{{t "login.submit"}}</button>
|
|
</form>
|
|
{{if .SSO}}<p><a class="btn" href="/login/oidc">{{t "login.with_sso" .SSO}}</a></p>{{end}}
|
|
{{if .Forgot}}<p class="note"><a href="/forgot">{{t "login.forgot"}}</a></p>{{end}}
|
|
{{if .SignupOpen}}<p class="note">{{th "login.no_account"}}</p>{{end}}
|
|
<footer><span>llmgw {{.Version}}</span><span><a href="/">{{t "common.about_gateway"}}</a></span>{{template "langs"}}</footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{define "profile"}}<!doctype html>
|
|
<html lang="{{lang}}"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{t "profile.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap">
|
|
<header>
|
|
<h1>{{t "profile.heading"}}</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>{{t "profile.new_key"}}</h2>
|
|
<p>{{t "profile.shown_once"}}</p>
|
|
<pre>{{.NewKey}}</pre>
|
|
<p>{{t "profile.store_it"}}</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>{{t "connect.ready" .ToolName}}</h2>
|
|
<p>{{th "connect.shown_once"}}</p>
|
|
{{if .Command}}
|
|
<h3>{{t "connect.one_command"}}</h3>
|
|
<p class="note">{{if eq .Tool "codex"}}{{t "connect.command_note_codex"}}{{else if eq .Tool "vscode"}}{{t "connect.command_note_vscode"}}{{else}}{{t "connect.command_note"}}{{end}}</p>
|
|
<div class="copyable"><pre>{{.Command}}</pre><button type="button" class="link copy">{{t "common.copy"}}</button></div>
|
|
{{end}}
|
|
{{range .Files}}
|
|
<h3>{{th "connect.by_hand" .Path}}</h3>
|
|
<div class="copyable"><pre>{{.Body}}</pre><button type="button" class="link copy">{{t "common.copy"}}</button>
|
|
<button type="button" class="link download-file" data-name="{{.Name}}">{{t "common.download"}}</button></div>
|
|
{{end}}
|
|
{{if .Fields}}
|
|
<h3>{{t "connect.fields"}}</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}}">{{t "common.copy"}}</button></td></tr>{{end}}
|
|
</table>
|
|
{{end}}
|
|
{{if .Link}}
|
|
<h3>{{t "connect.then"}}</h3>
|
|
<p><a href="{{.Link}}"><strong>{{t "connect.open_vscode"}}</strong></a></p>
|
|
{{else}}
|
|
<h3>{{t "connect.then"}}</h3>
|
|
{{end}}
|
|
<p>{{if eq .Tool "codex"}}<code>{{.Run}}</code>{{else}}{{.Run}}{{end}}</p>
|
|
{{range .Notes}}<p class="note">{{.}}</p>{{end}}
|
|
</section>
|
|
{{end}}
|
|
|
|
<h2 id="connect">{{t "connect.heading"}}</h2>
|
|
<p class="note">{{t "connect.intro"}}</p>
|
|
<p class="note">{{th "connect.own_code"}}</p>
|
|
<form method="post" action="/profile/connect#connected" class="connect-form">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
{{if .State}}<input type="hidden" name="state" value="{{.State}}">{{end}}
|
|
<div class="field-row">
|
|
<label>{{t "connect.tool"}}
|
|
<select name="tool">{{range .Tools}}<option value="{{.Key}}"{{if eq .Key $.Tool}} selected{{end}}>{{.Name}}</option>{{end}}</select></label>
|
|
<label>{{t "connect.model"}}
|
|
{{if .Models}}<select name="model">{{range .Models}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
|
{{else}}<input type="text" name="model" placeholder="{{t "connect.model_placeholder"}}" required>{{end}}</label>
|
|
{{if .Contexts}}<label>{{t "connect.spends_against"}}
|
|
<select name="context">{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select></label>{{end}}
|
|
<button type="submit">{{t "connect.submit"}}</button>
|
|
</div>
|
|
</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.
|
|
const copiedWord = {{t "common.copied"}}, copyPrompt = {{t "profile.copy_prompt"}};
|
|
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 = copiedWord; setTimeout(() => (b.textContent = was), 1500); };
|
|
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, () => window.prompt(copyPrompt, text));
|
|
else window.prompt(copyPrompt, 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>{{t "profile.create_key"}}</h2>
|
|
<form method="post" action="/profile/keys" class="row">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>{{t "profile.key_for"}}<input type="text" name="name" placeholder="{{t "profile.key_for_placeholder"}}" required></label>
|
|
{{if .Contexts}}
|
|
<label>{{t "profile.spends_against"}}
|
|
<select name="context">
|
|
{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
|
|
</select></label>
|
|
<button type="submit">{{t "profile.create"}}</button>
|
|
{{else}}
|
|
<p class="note">{{t "profile.no_project"}}</p>
|
|
{{end}}
|
|
</form>
|
|
|
|
<h2>{{t "profile.keys"}}</h2>
|
|
{{if .Keys}}
|
|
<table class="rows"><tr><th>{{t "profile.col.name"}}</th><th>{{t "profile.col.key"}}</th><th>{{t "profile.col.spends_against"}}</th><th>{{t "profile.col.created"}}</th><th>{{t "profile.col.last_used"}}</th><th>{{t "profile.col.state"}}</th><th></th></tr>
|
|
{{range .Keys}}<tr>
|
|
<td>{{.Name}}</td>
|
|
<td><code>{{.Prefix}}</code></td>
|
|
<td>{{if .ProjectID}}{{or (index $.Where .ProjectID) (t "profile.left_project")}}{{else}}{{t "profile.personal"}}{{end}}</td>
|
|
<td>{{date .CreatedAt}}</td>
|
|
<td>{{ago .LastUsedAt}}</td>
|
|
<td class="state state-{{state .}}">{{t (print "profile.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">{{t "profile.revoke"}}</button>
|
|
</form>
|
|
{{end}}</td>
|
|
</tr>{{end}}</table>
|
|
<p class="note">{{t "profile.revoke_note"}}</p>
|
|
{{else}}<p class="empty">{{t "profile.no_keys"}}</p>{{end}}
|
|
|
|
<h2>{{t "profile.where"}}</h2>
|
|
{{if .Memberships}}
|
|
<p>{{range $i, $m := .Memberships}}{{if $i}} · {{end}}{{$m.Path}} <span class="opt">{{t (print "profile.scope." $m.Scope)}}</span>{{end}}</p>
|
|
{{else}}<p class="empty">{{t "profile.no_memberships"}}</p>{{end}}
|
|
|
|
<h2>{{t "selfuse.heading"}}</h2>
|
|
{{with .Own}}
|
|
<div class="tiles own-use">
|
|
<div class="tile"><div class="v">{{compact .Requests}}</div><div class="k">{{t "selfuse.requests_30d"}}</div></div>
|
|
<div class="tile"><div class="v">{{compact .Tokens}}</div><div class="k">{{t "selfuse.tokens_30d"}}</div></div>
|
|
<div class="tile"><div class="v">{{.Spend}}</div><div class="k">{{t "selfuse.spend_30d"}}</div></div>
|
|
</div>
|
|
{{with .Chart}}<div class="chart-wrap"><h3 class="chart-title">{{t "selfuse.tokens_per_day"}}</h3>{{.}}</div>{{end}}
|
|
{{end}}
|
|
{{range .LimitsBy}}
|
|
<h3 class="limits-for">{{.Label}}{{with .Use}} <span class="opt">{{t "selfuse.place_use" (compact .Requests) (compact .Tokens)}}</span>{{end}}</h3>
|
|
{{range .Budgets}}
|
|
<div class="budget{{if .Over}} over{{else if .Near}} near{{end}}">
|
|
<div class="budget-head"><span>{{.Label}}</span><span>{{th "selfuse.budget_of" .Used .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>{{t "selfuse.col.limit"}}</th><th class="num">{{t "selfuse.col.value"}}</th></tr>
|
|
{{range .Rows}}<tr><td>{{.Name}}</td><td class="num">{{.Value}}</td></tr>{{end}}</table>
|
|
{{else}}<p class="empty">{{t "selfuse.no_limits"}}</p>{{end}}
|
|
{{else}}<p class="empty">{{t "selfuse.nowhere"}}</p>{{end}}
|
|
<p class="note">{{t "selfuse.strictest"}}</p>
|
|
|
|
{{with .Licences}}
|
|
<h2 id="licences">{{t "profile.licences"}}</h2>
|
|
{{if $.LicencesSaved}}<p>{{t "profile.licences.saved"}}</p>{{end}}
|
|
{{if $.LicenceError}}<p class="error">{{$.LicenceError}}</p>{{end}}
|
|
<p class="note">{{t "profile.licences.intro"}}</p>
|
|
<form method="post" action="/profile/licences" class="narrow">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<fieldset><legend>{{t "profile.licences.rules"}}</legend>
|
|
<label class="check"><input type="checkbox" name="non_commercial" value="1"{{if .NonCommercial}} checked{{end}}>
|
|
{{t "profile.licences.non_commercial"}}</label>
|
|
{{range .Regions}}<label class="check"><input type="checkbox" name="region" value="{{.}}"{{if has $.Licences.ExcludedRegions .}} checked{{end}}>
|
|
{{t "profile.licences.region" .}}</label>{{end}}
|
|
<label>{{t "profile.licences.regions_other"}}<input type="text" name="regions_other" placeholder="{{t "profile.licences.regions_example"}}" autocomplete="off"></label>
|
|
</fieldset>
|
|
<fieldset><legend>{{t "profile.licences.each"}}</legend>
|
|
{{range .Licences}}<label class="check"><input type="checkbox" name="licence" value="{{.Key}}"{{if .Excluded}} checked{{end}}>
|
|
{{if .Name}}{{if .URL}}<a href="{{.URL}}" rel="noopener noreferrer">{{.Name}}</a>{{else}}{{.Name}}{{end}}{{else}}{{t "profile.licences.unspecified"}}{{end}}
|
|
<span class="opt">{{if eq .Commercial "yes"}}{{t "profile.licences.commercial_yes"}} · {{else if eq .Commercial "no"}}{{t "profile.licences.commercial_no"}} · {{end}}{{with .ExcludedRegions}}{{t "profile.licences.not_in" (join .)}} · {{end}}{{join .Models}}</span></label>{{end}}
|
|
</fieldset>
|
|
<button type="submit">{{t "profile.licences.save"}}</button>
|
|
</form>
|
|
<p class="note">{{t "profile.licences.note"}}</p>
|
|
{{end}}
|
|
|
|
<h2>{{t "profile.password"}}</h2>
|
|
{{if .PasswordChanged}}<p>{{t "profile.password_changed"}}</p>{{end}}
|
|
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
|
|
<form method="post" action="/profile/password" autocomplete="on" class="row">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>{{t "profile.current_password"}}<input type="password" name="current" required
|
|
autocomplete="current-password"></label>
|
|
<label>{{t "profile.new_password"}}<input type="password" name="new" required
|
|
minlength="{{.MinPassword}}" autocomplete="new-password"></label>
|
|
<button type="submit">{{t "profile.change_password"}}</button>
|
|
</form>
|
|
<p class="note">{{t "profile.password_note"}}</p>
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{define "forgot"}}<!doctype html>
|
|
<html lang="{{lang}}"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{t "forgot.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap narrow">
|
|
<header><h1>{{t "forgot.heading"}}</h1></header>
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
{{if .Sent}}<p>{{t "forgot.sent"}}</p><p class="note"><a href="/login">{{t "login.submit"}}</a></p>
|
|
{{else if .NoMail}}<p>{{th "forgot.no_mail"}}</p>
|
|
{{else}}<p class="sub">{{t "forgot.sub"}}</p>
|
|
<form method="post" autocomplete="on">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>{{t "login.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
|
<button type="submit">{{t "forgot.submit"}}</button>
|
|
</form>{{end}}
|
|
<footer><span>llmgw {{.Version}}</span><span><a href="/login">{{t "login.heading"}}</a></span>{{template "langs"}}</footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{define "reset"}}<!doctype html>
|
|
<html lang="{{lang}}"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{t "reset.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
</head><body><div class="wrap narrow">
|
|
<header><h1>{{t "reset.heading"}}</h1></header>
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
{{if .Done}}<p>{{t "reset.done"}}</p><p class="note"><a href="/login">{{t "login.submit"}}</a></p>
|
|
{{else if .Bad}}<p class="error">{{t "reset.bad"}}</p><p class="note"><a href="/forgot">{{t "forgot.heading"}}</a></p>
|
|
{{else}}
|
|
<form method="post" autocomplete="off">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="hidden" name="token" value="{{.Token}}">
|
|
<label>{{t "reset.password"}}<input type="password" name="password" required minlength="{{.MinPassword}}" autocomplete="new-password"></label>
|
|
<label>{{t "reset.confirm"}}<input type="password" name="confirm" required minlength="{{.MinPassword}}" autocomplete="new-password"></label>
|
|
<p class="note">{{t "signup.password_rule" .MinPassword}}</p>
|
|
<button type="submit">{{t "reset.submit"}}</button>
|
|
</form>{{end}}
|
|
<footer><span>llmgw {{.Version}}</span>{{template "langs"}}</footer>
|
|
</div></body></html>{{end}}
|