Files
llmgw/internal/web/templates/account.html
T
Ilker ManapandClaude Opus 5.5 d8ce573bdb
CI / test (push) Failing after 7m1s
Slurm's shared files and users: one path everywhere, accounts as Linux users
slurm.shared mounts the site's NFS export, has an llmgw node export the path
over NFS to the Slurm machines alone (2049 in the firewall), or looks for a
file system the site mounts. slurm.users "llmgw" makes each active account a
Linux user on every Slurm machine: login from its e-mail, a UID from 200000
kept in slurm_users and never reused, its home on the shared path, its SSH
keys from the portal's Profile (or ctl slurm users keys) read by sshd from
/etc/llmgw/ssh-keys. server init --slurm chooses /shared from the first
machine and llmgw's users; --slurm-shared and --slurm-users change that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:34:34 +01:00

304 lines
16 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}}
{{with .Slurm}}
<h2 id="slurm">{{t "profile.slurm"}}</h2>
{{if .Saved}}<p>{{t "profile.slurm.saved"}}</p>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .Login}}<p class="note">{{t "profile.slurm.intro" .Login .UID .Home}}</p>
<pre><code>ssh {{.Login}}@{{.Host}}
sbatch job.sh</code></pre>{{end}}
<form method="post" action="/profile/ssh-keys" class="narrow">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>{{t "profile.slurm.keys"}}<textarea name="keys" rows="4" spellcheck="false" autocomplete="off" placeholder="{{t "profile.slurm.placeholder"}}">{{.Keys}}</textarea></label>
<button type="submit">{{t "profile.slurm.save"}}</button>
</form>
<p class="note">{{t "profile.slurm.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}}