aboutsummaryrefslogtreecommitdiff
path: root/templates
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /templates
downloadgit-forge-master.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'templates')
-rw-r--r--templates/admin.html236
-rw-r--r--templates/base.html23
-rw-r--r--templates/error.html5
-rw-r--r--templates/home.html16
-rw-r--r--templates/issue-new.html27
-rw-r--r--templates/issue.html62
-rw-r--r--templates/issues.html34
-rw-r--r--templates/login.html33
-rw-r--r--templates/nav.html112
-rw-r--r--templates/repo-settings.html122
-rw-r--r--templates/reset.html23
-rw-r--r--templates/set-password.html20
-rw-r--r--templates/settings.html126
-rw-r--r--templates/setup.html33
-rw-r--r--templates/totp-enroll.html96
-rw-r--r--templates/totp.html16
-rw-r--r--templates/wiki-edit.html27
-rw-r--r--templates/wiki.html37
18 files changed, 1048 insertions, 0 deletions
diff --git a/templates/admin.html b/templates/admin.html
new file mode 100644
index 0000000..871b061
--- /dev/null
+++ b/templates/admin.html
@@ -0,0 +1,236 @@
+{{define "title"}}Admin - {{.Site}}{{end}}
+{{define "main"}}
+<h2>This instance</h2>
+<form method="post" action="/admin/site" enctype="multipart/form-data">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="site_name">Name, shown in the header and in mail subjects</label>
+<input id="site_name" name="site_name" value="{{.Data.Site.Name}}" required>
+</p>
+<p>
+<label for="site_desc">Subtitle, under the name</label>
+<input id="site_desc" name="site_desc" value="{{.Data.Site.Desc}}">
+</p>
+<fieldset>
+<legend>Logo</legend>
+<p>
+{{if .Data.Site.Logo.Name}}
+Currently <code>{{.Data.Site.Logo.Name}}</code>.
+{{else}}
+Currently cgit's own.
+{{end}}
+</p>
+<p>
+<label for="logo_file">Upload one</label>
+<input id="logo_file" name="logo_file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon">
+<span class="dim">
+Drawn in the header at 96 pixels at most, and left at its own size below that.
+</span>
+</p>
+<p>
+<label class="inline" for="logo-cgit">
+<input id="logo-cgit" name="logo" type="checkbox" value="cgit">
+Use cgit's own logo instead
+</label>
+</p>
+</fieldset>
+
+<fieldset>
+<legend>Favicon</legend>
+<p>
+{{if .Data.Site.Icon.Name}}
+Currently <code>{{.Data.Site.Icon.Name}}</code>.
+{{else}}
+Currently cgit's own.
+{{end}}
+</p>
+<p>
+<label for="icon_file">Upload one</label>
+<input id="icon_file" name="icon_file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon">
+<span class="dim">
+Served at <code>/favicon.ico</code>, whatever type it is.
+</span>
+</p>
+<p>
+<label class="inline" for="icon-cgit">
+<input id="icon-cgit" name="icon" type="checkbox" value="cgit">
+Use cgit's own favicon instead
+</label>
+</p>
+</fieldset>
+<p class="dim">
+Both take a png, jpeg, gif, webp, or an icon, up to 512 KiB, and are kept in
+the config repository, so they are in the backup and in the history with
+everything else. SVG is refused: it is XML that can carry script, and this is
+served back to browsers.
+</p>
+<p><button type="submit">Save</button></p>
+</form>
+<p class="dim">
+Everything else in forge.json decides how the process starts, and is left to
+the file: a listener or a base url typed into a browser is a way to lock
+everybody out.
+</p>
+
+<h2>Repositories</h2>
+<table summary="repositories" class="list nowrap">
+<tr class="nohover">
+<th class="left">Name</th><th class="left">Visibility</th>
+<th class="left">Issues</th><th class="left">Wiki</th><th class="left"></th>
+</tr>
+{{range .Data.Repos}}
+<tr>
+<td><a href="/{{.Path}}/settings">{{.Path}}</a></td>
+<td>{{.Repo.Visibility}}</td>
+<td>{{.Repo.Issues}}</td>
+<td>{{.Repo.Wiki}}</td>
+<td>
+{{if not .Registered}}
+<form method="post" action="/admin/repo/adopt">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+<input type="hidden" name="path" value="{{.Path}}">
+<button type="submit">adopt</button>
+</form>
+{{end}}
+</td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="dim">None yet.</td></tr>
+{{end}}
+</table>
+<p class="dim">
+A repository with an adopt button has a directory but no entry in repos.json.
+It is served private with issues and wiki off until it is adopted or deleted.
+</p>
+
+<h3>Create a repository</h3>
+<form method="post" action="/admin/repo/create">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="section">Section, optional</label>
+<input id="section" name="section" autocapitalize="none">
+</p>
+<p>
+<label for="name">Name</label>
+<input id="name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="desc">Description</label>
+<input id="desc" name="desc">
+</p>
+{{template "knobs" .Data.New}}
+<p><button type="submit">Create</button></p>
+</form>
+
+<h2>Accounts</h2>
+{{with .Data.Link}}
+<div class="notice">
+One time link for {{$.Data.For}}, valid for two days. It both sets the password
+and confirms the address, and it dies the moment it is used.
+</div>
+<pre>{{.}}</pre>
+{{end}}
+
+<table summary="accounts" class="list nowrap">
+<tr class="nohover">
+<th class="left">Account</th><th class="left">Origin</th><th class="left">Email</th>
+<th class="left">Admin</th><th class="left">Second factor</th>
+</tr>
+{{range $name, $u := .Data.Users}}
+<tr>
+<td>{{$name}}</td>
+<td>{{$u.Origin}}</td>
+<td>{{with $u.Email}}{{.}}{{if not $u.EmailConfirmed}} <span class="dim">unconfirmed</span>{{end}}{{end}}</td>
+<td>{{if $u.Admin}}yes{{end}}</td>
+<td>{{if $u.TOTP}}yes{{else}}<span class="dim">none</span>{{end}}</td>
+</tr>
+{{end}}
+</table>
+
+<h3>Create an account</h3>
+<p>
+Creating an account issues a one time set-password link rather than a password,
+so an admin never handles one. That link is a way in that outlives this session,
+so this asks for your own authenticator code.
+</p>
+<form method="post" action="/admin/user/create">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="account">Account name</label>
+<input id="account" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="email">Email address, optional</label>
+<input id="email" name="email" type="email">
+</p>
+<p>
+<label for="create-code">Your authenticator code</label>
+<input id="create-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p class="dim">
+The admin flag is not set here. Handing it out is its own act, below.
+</p>
+<p><button type="submit">Create</button></p>
+</form>
+
+<div class="danger">
+<h3>Changes that hand out power, or cannot be undone</h3>
+<p>
+Each of these asks for your own authenticator code as well, so that a session
+somebody else is sitting in front of cannot use them.
+</p>
+
+<h4>Admin flag</h4>
+<form method="post" action="/admin/user/admin">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="flag-name">Account</label>
+<input id="flag-name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="flag-admin">
+<input id="flag-admin" name="admin" type="checkbox" value="yes">
+Hold the global admin flag. Leave this unticked to take it away.
+</label>
+</p>
+<p>
+<label for="flag-code">Your authenticator code</label>
+<input id="flag-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Set the flag</button></p>
+</form>
+
+<h4>Clear an authenticator</h4>
+<p>
+The way back in for somebody who has lost both their authenticator and their
+recovery codes. It signs every session for that account out.
+</p>
+<form method="post" action="/admin/user/totp">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="totp-name">Account</label>
+<input id="totp-name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="totp-code">Your authenticator code</label>
+<input id="totp-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Clear it</button></p>
+</form>
+
+<h4>Delete an account</h4>
+<p>This also drops every grant that pointed at it.</p>
+<form method="post" action="/admin/user/delete">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="delete-name">Account name</label>
+<input id="delete-name" name="name" autocapitalize="none" required>
+</p>
+<p>
+<label for="delete-code">Your authenticator code</label>
+<input id="delete-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Delete</button></p>
+</form>
+</div>
+{{end}}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..0f1751f
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{block "title" .}}{{.Site}}{{end}}</title>
+<link rel="stylesheet" href="/cgit.css">
+<link rel="shortcut icon" href="/favicon.ico">
+</head>
+<body>
+<div id="cgit">
+{{template "header" .}}
+{{template "tabrow" .}}
+<div class="content">
+{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
+{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
+{{block "main" .}}{{end}}
+</div>
+{{/* cgit's footer names cgit, so this one names the forge. */}}
+<div class="footer">generated by {{.Site}} at {{.Now}}</div>
+</div>
+</body>
+</html>
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..9c39077
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,5 @@
+{{define "title"}}{{.Data.Status}} {{.Data.Text}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>{{.Data.Status}} {{.Data.Text}}</h2>
+<p><a href="/">Back to the index</a></p>
+{{end}}
diff --git a/templates/home.html b/templates/home.html
new file mode 100644
index 0000000..a7460f8
--- /dev/null
+++ b/templates/home.html
@@ -0,0 +1,16 @@
+{{define "title"}}{{.Site}}{{end}}
+{{define "main"}}
+<table summary="repository list" class="list nowrap">
+<tr class="nohover"><th class="left">Name</th><th class="left">Description</th></tr>
+{{range .Data.Repos}}
+<tr>
+<td><a href="/{{.Path}}/">{{.Path}}</a>{{if eq .Repo.Visibility "private"}} <span class="dim">private</span>{{end}}</td>
+<td>{{.Repo.Desc}}</td>
+</tr>
+{{else}}
+<tr><td colspan="2" class="dim">
+{{if $.User}}Nothing you can read yet.{{else}}Nothing public here. <a href="/login">Sign in</a> to see the rest.{{end}}
+</td></tr>
+{{end}}
+</table>
+{{end}}
diff --git a/templates/issue-new.html b/templates/issue-new.html
new file mode 100644
index 0000000..ed102aa
--- /dev/null
+++ b/templates/issue-new.html
@@ -0,0 +1,27 @@
+{{define "title"}}New issue, {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Open an issue</h2>
+<form method="post" action="/{{.Data.Path}}/issues">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="title">Title</label>
+<input id="title" name="title" required maxlength="200">
+</p>
+<p>
+<label for="body">Description. Markdown, and raw HTML is not rendered.</label>
+<textarea id="body" name="body" rows="12"></textarea>
+</p>
+{{with .Data.Labels}}
+<fieldset>
+<legend>Labels</legend>
+{{range .}}
+<label class="inline" for="label-{{.}}">
+<input id="label-{{.}}" type="checkbox" name="label" value="{{.}}">
+{{.}}
+</label>
+{{end}}
+</fieldset>
+{{end}}
+<p><button type="submit">Open</button></p>
+</form>
+{{end}}
diff --git a/templates/issue.html b/templates/issue.html
new file mode 100644
index 0000000..e5135a0
--- /dev/null
+++ b/templates/issue.html
@@ -0,0 +1,62 @@
+{{define "title"}}{{.Data.Issue.Title}} - {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+{{$issue := .Data.Issue}}
+<h2>{{$issue.Title}} <span class="dim">#{{$issue.ID}}</span></h2>
+<p>
+<span class="state-{{$issue.State}}">{{$issue.State}}</span>
+<span class="dim">opened by</span> {{template "who" $issue.Author}}
+<span class="dim">on {{$issue.Created.Format "2006-01-02 15:04"}} UTC</span>
+{{range $issue.Labels}}<span class="label">{{.}}</span>{{end}}
+</p>
+
+{{if .Data.Triage}}
+<div class="bar">
+<form method="post" action="/{{.Data.Path}}/issues/{{$issue.ID}}/state">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<button type="submit">{{if eq $issue.State "open"}}close this issue{{else}}reopen this issue{{end}}</button>
+</form>
+{{with .Data.Labels}}
+<form method="post" action="/{{$.Data.Path}}/issues/{{$issue.ID}}/labels">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+{{range .}}
+<label class="inline" for="label-{{.Name}}">
+<input id="label-{{.Name}}" type="checkbox" name="label" value="{{.Name}}" {{if .On}}checked{{end}}>
+{{.Name}}
+</label>
+{{end}}
+<button type="submit">set labels</button>
+</form>
+{{end}}
+</div>
+{{end}}
+
+<div class="comment">
+<div class="byline">{{template "who" $issue.Author}} <span class="dim">opened this</span></div>
+<article>{{.Data.Rendered}}</article>
+</div>
+
+{{range .Data.Comments}}
+<div class="comment">
+<div class="byline">
+{{template "who" .Comment.Author}}
+<span class="dim">commented on {{.Comment.Created.Format "2006-01-02 15:04"}} UTC</span>
+</div>
+<article>{{.Rendered}}</article>
+</div>
+{{end}}
+
+{{if .Data.Can}}
+<h3>Comment</h3>
+<form method="post" action="/{{.Data.Path}}/issues/{{$issue.ID}}/comment">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="body">
+Markdown: **bold**, `code`, a list, or four spaces for a code block. Raw HTML
+is not rendered.
+</label>
+<textarea id="body" name="body" rows="8" required></textarea>
+</p>
+<p><button type="submit">Comment</button></p>
+</form>
+{{end}}
+{{end}}
diff --git a/templates/issues.html b/templates/issues.html
new file mode 100644
index 0000000..e0907ce
--- /dev/null
+++ b/templates/issues.html
@@ -0,0 +1,34 @@
+{{define "title"}}Issues, {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<div class="bar">
+<form method="get" action="/{{.Data.Path}}/issues">
+<input id="q" name="q" value="{{.Data.Query}}" placeholder="search the tracker" size="30">
+<input type="hidden" name="state" value="{{.Data.State}}">
+<button type="submit">search</button>
+</form>
+<span>
+{{if eq .Data.State "open"}}<strong>open</strong>{{else}}<a href="/{{.Data.Path}}/issues?state=open&amp;q={{.Data.Query}}">open</a>{{end}}
+{{if eq .Data.State "closed"}}<strong>closed</strong>{{else}}<a href="/{{.Data.Path}}/issues?state=closed&amp;q={{.Data.Query}}">closed</a>{{end}}
+<a href="/{{.Data.Path}}.issues/">history</a>
+{{if .Data.Can}}<a href="/{{.Data.Path}}/issues/new">open an issue</a>{{end}}
+</span>
+</div>
+
+<table summary="issues" class="list nowrap">
+<tr class="nohover">
+<th class="left">#</th><th class="left">Title</th>
+<th class="left">Labels</th><th class="left">Author</th><th class="left">Updated</th>
+</tr>
+{{range .Data.Issues}}
+<tr>
+<td>{{.ID}}</td>
+<td><a href="/{{$.Data.Path}}/issues/{{.ID}}">{{.Title}}</a></td>
+<td class="dim">{{range .Labels}}<span class="label">{{.}}</span> {{end}}</td>
+<td>{{template "who" .Author}}</td>
+<td class="dim">{{.Updated.Format "2006-01-02"}}</td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="dim">Nothing here.</td></tr>
+{{end}}
+</table>
+{{end}}
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..eb9dcd9
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,33 @@
+{{define "title"}}Sign in - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Sign in</h2>
+<form method="post" action="/login">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="name">Account</label>
+<input id="name" name="name" autocomplete="username" autocapitalize="none" required>
+</p>
+<p>
+<label for="password">Password</label>
+<input id="password" name="password" type="password" autocomplete="current-password" required>
+</p>
+<p><button type="submit">Sign in</button></p>
+</form>
+{{if .MailOff}}
+<p class="dim">
+Mail is turned off on this instance, so there is no password reset. An admin
+can issue you a one time link instead.
+</p>
+{{else}}
+<p><a href="/reset">Forgotten your password?</a></p>
+{{end}}
+
+{{with .Data.Providers}}
+<h3>Or sign in through</h3>
+<ul>
+{{range .}}
+<li><a href="/oidc/{{.Key}}">{{.Label}}</a></li>
+{{end}}
+</ul>
+{{end}}
+{{end}}
diff --git a/templates/nav.html b/templates/nav.html
new file mode 100644
index 0000000..82f0714
--- /dev/null
+++ b/templates/nav.html
@@ -0,0 +1,112 @@
+{{/*
+ cgit's own header markup, rendered in place of the one noheader=1 turns off,
+ so that both kinds of page carry the same header and it can name the account.
+*/}}
+{{define "header"}}
+<table id="header">
+<tr>
+<td class="logo" rowspan="2"><a href="/"><img src="{{.Logo}}" alt="home"></a></td>
+<td class="main">
+{{if .Repo}}<a href="/">{{.Site}}</a> : <a href="/{{.Repo.Path}}/">{{.Repo.Path}}</a>
+{{else}}<a href="/">{{.Site}}</a>{{end}}
+</td>
+<td class="form">
+{{if .User}}
+{{if or .Admin .MustEnrol}}<a href="/admin">admin</a>{{end}}
+<a href="/settings">{{.User}}</a>
+<form method="post" action="/logout">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<button type="submit" class="link">sign out</button>
+</form>
+{{else}}
+<a href="/login">sign in</a>
+{{end}}
+</td>
+</tr>
+<tr><td class="sub">{{if .Repo}}{{.Repo.Desc}}{{else}}{{.Desc}}{{end}}</td></tr>
+</table>
+{{end}}
+
+{{/* The forge's sections, in cgit's tab markup. */}}
+{{define "tabs"}}{{with .Repo}}{{if .Issues}}<a href="/{{.Path}}/issues"{{if eq $.Repo.Active "issues"}} class="active"{{end}}>issues</a>{{end}}{{if .Wiki}}<a href="/{{.Path}}/wiki"{{if eq $.Repo.Active "wiki"}} class="active"{{end}}>wiki</a>{{end}}{{if .Settings}}<a href="/{{.Path}}/settings"{{if eq $.Repo.Active "settings"}} class="active"{{end}}>settings</a>{{end}}{{end}}{{end}}
+
+{{/*
+ cgit's row copied, for the forge's own pages where cgit renders nothing: the
+ same tabs in the same order, and the same search cell.
+*/}}
+{{define "tabrow"}}{{with .Repo}}
+<table class="tabs"><tr><td>
+<a href="/{{.Path}}/about/">about</a><a href="/{{.Path}}/">summary</a><a href="/{{.Path}}/refs/">refs</a><a href="/{{.Path}}/log/">log</a><a href="/{{.Path}}/tree/">tree</a><a href="/{{.Path}}/commit/">commit</a><a href="/{{.Path}}/diff/">diff</a>{{template "tabs" $}}</td>
+<td class="form"><form class="right" method="get" action="/{{.Path}}/log/">
+<select name="qt">
+<option value="grep">log msg</option>
+<option value="author">author</option>
+<option value="committer">committer</option>
+<option value="range">range</option>
+</select>
+<input class="txt" type="search" size="10" name="q" value="">
+<input type="submit" value="search">
+</form>
+</td></tr></table>
+{{end}}{{end}}
+
+{{/*
+ In the layout because every page is parsed as its own set, and a partial
+ defined in one page is invisible to the next.
+*/}}
+{{define "who"}}{{if .}}<img class="avatar" src="/avatar/{{.}}" alt=""> {{.}}{{else}}<span class="dim">nobody</span>{{end}}{{end}}
+
+{{/*
+ One fragment for both the create form and the settings form, so that the two
+ cannot drift apart on what members and users mean.
+*/}}
+{{define "knobs"}}
+<fieldset>
+<legend>Who may read it</legend>
+<label class="choice" for="v-private">
+<input id="v-private" type="radio" name="visibility" value="private" {{if eq .Visibility "private"}}checked{{end}}>
+<strong>private</strong> <span class="dim">accounts holding a grant on it, and global admins</span>
+</label>
+<label class="choice" for="v-public">
+<input id="v-public" type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}>
+<strong>public</strong> <span class="dim">anybody, including visitors who are not signed in</span>
+</label>
+</fieldset>
+
+<fieldset>
+<legend>Who may file issues</legend>
+<label class="choice" for="i-disabled">
+<input id="i-disabled" type="radio" name="issues" value="disabled" {{if eq .Issues "disabled"}}checked{{end}}>
+<strong>disabled</strong> <span class="dim">nobody, and the tab is hidden</span>
+</label>
+<label class="choice" for="i-members">
+<input id="i-members" type="radio" name="issues" value="members" {{if eq .Issues "members"}}checked{{end}}>
+<strong>members</strong> <span class="dim">anybody holding a grant here, even a read grant</span>
+</label>
+<label class="choice" for="i-users">
+<input id="i-users" type="radio" name="issues" value="users" {{if eq .Issues "users"}}checked{{end}}>
+<strong>users</strong> <span class="dim">any signed in account. Public repositories only</span>
+</label>
+</fieldset>
+
+<fieldset>
+<legend>Who may edit the wiki</legend>
+<label class="choice" for="w-disabled">
+<input id="w-disabled" type="radio" name="wiki" value="disabled" {{if eq .Wiki "disabled"}}checked{{end}}>
+<strong>disabled</strong> <span class="dim">nobody, and the tab is hidden</span>
+</label>
+<label class="choice" for="w-members">
+<input id="w-members" type="radio" name="wiki" value="members" {{if eq .Wiki "members"}}checked{{end}}>
+<strong>members</strong> <span class="dim">anybody holding a grant here</span>
+</label>
+<label class="choice" for="w-users">
+<input id="w-users" type="radio" name="wiki" value="users" {{if eq .Wiki "users"}}checked{{end}}>
+<strong>users</strong> <span class="dim">any signed in account. Public repositories only</span>
+</label>
+</fieldset>
+<p class="dim">
+Reading is visibility's job alone. The other two say who may write, so a
+repository can take bug reports and documentation fixes from people who will
+never push code. users on a private repository is refused rather than coerced.
+</p>
+{{end}}
diff --git a/templates/repo-settings.html b/templates/repo-settings.html
new file mode 100644
index 0000000..d90f744
--- /dev/null
+++ b/templates/repo-settings.html
@@ -0,0 +1,122 @@
+{{define "title"}}{{.Data.Path}} settings - {{.Site}}{{end}}
+{{define "main"}}
+{{$repo := .Data.Repo}}
+<h2>Settings</h2>
+
+{{if not .Data.Enrolled}}
+<div class="error">
+This account has no authenticator, so it cannot change who may read or write
+this repository, and cannot change grants.
+<a href="/settings/totp">Enrol one</a> and come back.
+</div>
+{{end}}
+
+<form method="post" action="/{{.Data.Path}}/settings">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="desc">Description, shown in the header and in the index</label>
+<input id="desc" name="desc" value="{{$repo.Desc}}">
+</p>
+{{template "knobs" $repo}}
+<p>
+<label for="labels">Labels an issue may carry, one per line</label>
+<textarea id="labels" name="labels" rows="5">{{.Data.Labels}}</textarea>
+</p>
+<p>
+<label for="settings-code">Your authenticator code</label>
+<input id="settings-code" name="code" inputmode="numeric" autocomplete="one-time-code">
+<span class="dim">
+Needed only to move one of the three above: who may read or write outlives this
+session, the way a grant does. A description or a label does not.
+</span>
+</p>
+<p><button type="submit">Save</button></p>
+</form>
+
+<h3>Grants</h3>
+<p class="dim">
+read clones and browses a private repository. write adds pushing to it. admin
+adds this page. A global admin holds admin everywhere without appearing here.
+A push token can be minted for any repository an account can reach, and carries
+the access that account already has there and nothing more.
+</p>
+<p class="dim">
+Access handed out here outlives the session that hands it out, so changing a
+grant asks for your own authenticator code as well.
+</p>
+<table summary="grants" class="list nowrap">
+<tr class="nohover"><th class="left">Account</th><th class="left">Level</th><th class="left"></th></tr>
+{{range $account, $level := $repo.Grants}}
+<tr>
+<td>{{template "who" $account}}</td>
+<td>{{$level}}</td>
+<td>
+{{if $.Data.Enrolled}}
+<form method="post" action="/{{$.Data.Path}}/settings/grant">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+<input type="hidden" name="account" value="{{$account}}">
+<input name="code" size="6" inputmode="numeric" autocomplete="one-time-code" aria-label="Your authenticator code" placeholder="code" required>
+<button type="submit">revoke</button>
+</form>
+{{end}}
+</td>
+</tr>
+{{else}}
+<tr><td colspan="3" class="dim">Nobody, so only global admins can reach it.</td></tr>
+{{end}}
+</table>
+
+{{if .Data.Enrolled}}
+<form method="post" action="/{{.Data.Path}}/settings/grant">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="account">Account</label>
+<input id="account" name="account" autocapitalize="none" required>
+</p>
+<fieldset>
+<legend>Level</legend>
+<label class="choice" for="g-read">
+<input id="g-read" type="radio" name="level" value="read" checked>
+<strong>read</strong> <span class="dim">clone and browse it</span>
+</label>
+<label class="choice" for="g-write">
+<input id="g-write" type="radio" name="level" value="write">
+<strong>write</strong> <span class="dim">and push to it</span>
+</label>
+<label class="choice" for="g-admin">
+<input id="g-admin" type="radio" name="level" value="admin">
+<strong>admin</strong> <span class="dim">and change this page</span>
+</label>
+</fieldset>
+<p>
+<label for="grant-code">Your authenticator code</label>
+<input id="grant-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Grant</button></p>
+</form>
+{{end}}
+
+{{if .Data.Delete}}
+<div class="danger">
+<h3>Delete this repository</h3>
+<p>
+This removes the repository, its issues, and its wiki together. There is no
+undo and the history goes with it. Type <code>{{.Data.Path}}</code> to confirm,
+and your own authenticator code, so that a session somebody else is sitting in
+front of cannot do it.
+</p>
+<form method="post" action="/{{.Data.Path}}/settings/delete">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="confirm">Repository path</label>
+<input id="confirm" name="confirm" autocapitalize="none" required>
+</p>
+<p>
+<label for="code">Your authenticator code</label>
+<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+<p><button type="submit">Delete it</button></p>
+</form>
+</div>
+{{end}}
+{{end}}
diff --git a/templates/reset.html b/templates/reset.html
new file mode 100644
index 0000000..9025f2e
--- /dev/null
+++ b/templates/reset.html
@@ -0,0 +1,23 @@
+{{define "title"}}Forgotten password - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Forgotten password</h2>
+{{if .MailOff}}
+<div class="error">
+Mail is turned off on this instance, so no link can be sent. Ask an admin for
+a one time link.
+</div>
+{{end}}
+<p>
+This mails a one time link to the address on file for the account. The answer
+below is the same whether or not the account exists, so that this page cannot
+be used to find out which names are taken.
+</p>
+<form method="post" action="/reset">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="name">Account name</label>
+<input id="name" name="name" autocomplete="username" autocapitalize="none" required>
+</p>
+<p><button type="submit">Send the link</button></p>
+</form>
+{{end}}
diff --git a/templates/set-password.html b/templates/set-password.html
new file mode 100644
index 0000000..6a38413
--- /dev/null
+++ b/templates/set-password.html
@@ -0,0 +1,20 @@
+{{define "title"}}Set a password - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Set a password for {{.Data.Name}}</h2>
+<p>This link works once, and setting the password is what spends it.</p>
+<form method="post" action="/set-password">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="name" value="{{.Data.Name}}">
+<input type="hidden" name="expires" value="{{.Data.Expires}}">
+<input type="hidden" name="t" value="{{.Data.Token}}">
+<p>
+<label for="new">Password</label>
+<input id="new" name="new" type="password" autocomplete="new-password" required>
+</p>
+<p>
+<label for="again">Password again</label>
+<input id="again" name="again" type="password" autocomplete="new-password" required>
+</p>
+<p><button type="submit">Set the password</button></p>
+</form>
+{{end}}
diff --git a/templates/settings.html b/templates/settings.html
new file mode 100644
index 0000000..8b2f0f1
--- /dev/null
+++ b/templates/settings.html
@@ -0,0 +1,126 @@
+{{define "title"}}Settings - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Account</h2>
+<p>
+Signed in as {{template "who" .User}}{{if .Admin}}, a global admin{{end}}.
+</p>
+
+<h3>Email and notifications</h3>
+{{if .MailOff}}
+<p class="dim">
+Mail is turned off on this instance, so nothing is sent. The address below is
+still what commits made through the web are attributed to.
+</p>
+{{else}}
+<p class="dim">
+An address receives nothing until a link sent to it has been followed. Notices
+about your own account ignore the toggle below, because they are security facts
+rather than activity.
+</p>
+{{end}}
+<form method="post" action="/settings/email">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="email">Email address</label>
+<input id="email" name="email" type="email" value="{{.Data.Email}}">
+{{if .Data.Email}}{{if not .Data.EmailConfirmed}}<span class="dim">unconfirmed</span>{{end}}{{end}}
+</p>
+<p>
+<label for="notify">
+<input id="notify" name="notify" type="checkbox" value="yes" {{if .Data.Notify}}checked{{end}}>
+Send me issue and repository notifications
+</label>
+</p>
+<p><button type="submit">Save</button></p>
+</form>
+
+{{if .Data.Local}}
+<h3>Change password</h3>
+<form method="post" action="/settings/password">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="current">Current password</label>
+<input id="current" name="current" type="password" autocomplete="current-password" required>
+</p>
+<p>
+<label for="new">New password</label>
+<input id="new" name="new" type="password" autocomplete="new-password" required>
+</p>
+<p>
+<label for="again">New password again</label>
+<input id="again" name="again" type="password" autocomplete="new-password" required>
+</p>
+<p><button type="submit">Change password</button></p>
+</form>
+{{else}}
+<h3>Password</h3>
+<p>This account signs in through an external provider, so there is no password here.</p>
+{{end}}
+
+<h3>Push tokens</h3>
+<p class="dim">
+Git over HTTP takes a token, never your password. A token is scoped to one
+repository and carries the access you already have there: clone if you can read
+it, push if you can write it, the wiki if you may edit it. It carries no second
+factor, and it can be revoked on its own without touching anything else.
+</p>
+<pre>git clone {{.Base}}/section/repo.git
+Username: {{.User}}
+Password: the token</pre>
+{{with .Data.Secret}}
+<div class="notice">Token for {{$.User}}, shown once:</div>
+<pre id="token">{{.}}</pre>
+{{end}}
+<table summary="push tokens" class="list nowrap">
+<tr class="nohover">
+<th class="left">Id</th><th class="left">Repository</th>
+<th class="left">Made</th><th class="left">Last used</th><th class="left"></th>
+</tr>
+{{range .Data.Tokens}}
+<tr>
+<td>{{.ID}}</td>
+<td><a href="/{{.Repo}}/">{{.Repo}}</a></td>
+<td class="dim">{{if .Created}}{{.Created}}{{else}}unknown{{end}}</td>
+<td class="dim">{{if .Used}}{{.Used}}{{else}}never{{end}}</td>
+<td>
+<form method="post" action="/settings/tokens/revoke">
+<input type="hidden" name="csrf" value="{{$.CSRF}}">
+<input type="hidden" name="id" value="{{.ID}}">
+<button type="submit">revoke</button>
+</form>
+</td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="dim">None. Mint one below to push over HTTP.</td></tr>
+{{end}}
+</table>
+<p class="dim">
+Dates are days, not times: a write on every request git makes would be a write
+on every request git makes. A token that has never been used, or was last used
+a long time ago, is one worth revoking.
+</p>
+<form method="post" action="/settings/tokens">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="repo">Repository you can reach</label>
+<input id="repo" name="repo" placeholder="section/repo" autocapitalize="none" required>
+</p>
+<p><button type="submit">Mint a token</button></p>
+</form>
+
+<h3>Authenticator</h3>
+<p>
+<a href="/settings/totp">Second factor</a>. An account with the global admin
+flag has to have one before it can administer anything.
+</p>
+
+<h3>Sessions</h3>
+<p>
+Signing out everywhere invalidates every session cookie ever issued for this
+account, including this browser's.
+</p>
+<form method="post" action="/settings/sessions">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p><button type="submit">Sign out everywhere</button></p>
+</form>
+{{end}}
diff --git a/templates/setup.html b/templates/setup.html
new file mode 100644
index 0000000..9089bd3
--- /dev/null
+++ b/templates/setup.html
@@ -0,0 +1,33 @@
+{{define "title"}}Set up - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Set up {{.Site}}</h2>
+<p>
+This creates the first account and gives it the global admin flag. The setup
+token was printed to the log when git-forge started, and this page is gone once
+an account exists.
+</p>
+<form method="post" action="/setup">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<p>
+<label for="token">Setup token</label>
+<input id="token" name="token" required>
+</p>
+<p>
+<label for="name">Account name</label>
+<input id="name" name="name" autocomplete="username" autocapitalize="none" required>
+</p>
+<p>
+<label for="email">Email address, optional</label>
+<input id="email" name="email" type="email" autocomplete="email">
+</p>
+<p>
+<label for="password">Password</label>
+<input id="password" name="password" type="password" autocomplete="new-password" required>
+</p>
+<p>
+<label for="again">Password again</label>
+<input id="again" name="again" type="password" autocomplete="new-password" required>
+</p>
+<p><button type="submit">Create the first admin</button></p>
+</form>
+{{end}}
diff --git a/templates/totp-enroll.html b/templates/totp-enroll.html
new file mode 100644
index 0000000..da3d301
--- /dev/null
+++ b/templates/totp-enroll.html
@@ -0,0 +1,96 @@
+{{define "title"}}Authenticator - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Authenticator</h2>
+
+{{with .Data.Codes}}
+<h3>Recovery codes</h3>
+<p>Each of these works once, in place of a code. They are not shown again.</p>
+<pre>{{range .}}{{.}}
+{{end}}</pre>
+{{end}}
+
+{{if .Data.Enrolled}}
+<p>
+This account has an authenticator, and {{.Data.Left}} recovery codes left.
+</p>
+
+<h3>New recovery codes</h3>
+<p>Making new ones stops the old ones working.</p>
+<form method="post" action="/settings/totp/codes">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+{{if .Data.Local}}
+<p>
+<label for="codes-current">Current password</label>
+<input id="codes-current" name="current" type="password" autocomplete="current-password" required>
+</p>
+{{else}}
+<p>
+<label for="codes-code">Code from the authenticator</label>
+<input id="codes-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+{{end}}
+<p><button type="submit">Make new recovery codes</button></p>
+</form>
+
+<h3>Remove</h3>
+{{if .Data.Required}}
+<p>
+This account holds the global admin flag, so it must keep an authenticator.
+Another admin has to take the flag away first.
+</p>
+{{else}}
+<p>Removing it signs out every session for this account.</p>
+<form method="post" action="/settings/totp/remove">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+{{if .Data.Local}}
+<p>
+<label for="current">Current password</label>
+<input id="current" name="current" type="password" autocomplete="current-password" required>
+</p>
+{{else}}
+<p>
+<label for="remove-code">Code from the authenticator</label>
+<input id="remove-code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+{{end}}
+<p><button type="submit">Remove the authenticator</button></p>
+</form>
+{{end}}
+
+{{else}}
+<p>
+Type this secret into an authenticator, or give it the URI. There is no QR
+code: it would be useless in a terminal browser.
+</p>
+<pre>{{.Data.Secret}}
+
+{{.Data.URI}}</pre>
+<p>
+Then prove it took, which is what enrols it. Enrolling signs out every other
+session.
+{{if .Data.Local}}
+It takes the password as well: a stolen cookie must not be able to put a second
+factor on this account and lock its owner out.
+{{else}}
+This account signs in through an external provider and has no password here, so
+enrolling rests on this session alone. The address on file is told, and an admin
+can clear an authenticator you did not enrol.
+{{end}}
+</p>
+<form method="post" action="/settings/totp">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="secret" value="{{.Data.Secret}}">
+<p>
+<label for="code">Code from the authenticator</label>
+<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
+</p>
+{{if .Data.Local}}
+<p>
+<label for="enrol-current">Current password</label>
+<input id="enrol-current" name="current" type="password" autocomplete="current-password" required>
+</p>
+{{end}}
+<p><button type="submit">Enroll</button></p>
+</form>
+{{end}}
+{{end}}
diff --git a/templates/totp.html b/templates/totp.html
new file mode 100644
index 0000000..329d6cc
--- /dev/null
+++ b/templates/totp.html
@@ -0,0 +1,16 @@
+{{define "title"}}Authenticator code - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Authenticator code</h2>
+<p>The password was right. Now the six digit code, or one recovery code.</p>
+<form method="post" action="/login/totp">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="name" value="{{.Data.Name}}">
+<input type="hidden" name="expires" value="{{.Data.Expires}}">
+<input type="hidden" name="t" value="{{.Data.Token}}">
+<p>
+<label for="code">Code</label>
+<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus required>
+</p>
+<p><button type="submit">Sign in</button></p>
+</form>
+{{end}}
diff --git a/templates/wiki-edit.html b/templates/wiki-edit.html
new file mode 100644
index 0000000..ed896d2
--- /dev/null
+++ b/templates/wiki-edit.html
@@ -0,0 +1,27 @@
+{{define "title"}}Edit {{.Data.Page}} - {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Edit wiki{{with .Data.Page}} <span class="dim">{{.}}</span>{{end}}</h2>
+
+{{with .Data.Preview}}
+<h3>Preview</h3>
+<article>{{.}}</article>
+<hr>
+{{end}}
+
+<form method="post" action="/{{.Data.Path}}/wiki/{{.Data.Page}}">
+<input type="hidden" name="csrf" value="{{.CSRF}}">
+<input type="hidden" name="base" value="{{.Data.Base}}">
+<p>
+<label for="body">{{.Data.File}}, markdown</label>
+<textarea id="body" name="body" rows="24">{{.Data.Body}}</textarea>
+</p>
+<p>
+<button type="submit" name="action" value="save">Save</button>
+<button type="submit" name="action" value="preview">Preview</button>
+</p>
+</form>
+<p class="dim">
+Links between pages are plain relative markdown links, and a subdirectory's
+index.md is served at the subdirectory itself.
+</p>
+{{end}}
diff --git a/templates/wiki.html b/templates/wiki.html
new file mode 100644
index 0000000..36305c6
--- /dev/null
+++ b/templates/wiki.html
@@ -0,0 +1,37 @@
+{{define "title"}}Wiki {{.Data.Page}} - {{.Data.Path}} - {{.Site}}{{end}}
+{{define "main"}}
+<h2>Wiki{{with .Data.Page}} <span class="dim">{{.}}</span>{{end}}</h2>
+
+{{with .Data.Pages}}
+<p>
+Pages:
+{{range .}}<a href="/{{$.Data.Path}}/wiki/{{.}}">{{if .}}{{.}}{{else}}index{{end}}</a>&#32;
+{{end}}
+</p>
+{{end}}
+
+{{if .Data.Can}}
+<form method="get" action="/{{.Data.Path}}/wiki/">
+<p>
+<label for="page">New page, or an existing one to edit</label>
+<input id="page" name="page" placeholder="setup, or runbooks/dns" autocapitalize="none" required>
+<button type="submit">Write it</button>
+</p>
+</form>
+{{end}}
+
+{{if .Data.Empty}}
+<p>This wiki has no pages yet.</p>
+{{if .Data.Can}}<p><a href="/{{.Data.Path}}/wiki?edit">Write the first one</a>.</p>{{end}}
+{{else if .Data.Missing}}
+<p>There is no page here yet.</p>
+{{if .Data.Can}}<p><a href="/{{.Data.Path}}/wiki/{{.Data.Page}}?edit">Write it</a>.</p>{{end}}
+{{else}}
+<article>{{.Data.Rendered}}</article>
+<p>
+{{if .Data.Can}}<a href="/{{.Data.Path}}/wiki/{{.Data.Page}}?edit">Edit</a>{{end}}
+<a href="/{{.Data.Path}}.wiki/log/{{.Data.File}}">History</a>
+</p>
+{{end}}
+
+{{end}}