feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards. Changes include: - Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management. - Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history. - Implementing admin user management (`/admin/users`) for generating invite links and managing user states. - Updating the bbolt database schema to store users, sessions, invites, and collections. - Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
This commit is contained in:
19
backend/templates/pages/account.html
Normal file
19
backend/templates/pages/account.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "account.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="auth-view" aria-labelledby="account-title">
|
||||
<div class="card auth-card">
|
||||
<div class="card-content">
|
||||
<p class="kicker">Account</p>
|
||||
<h1 id="account-title">Settings</h1>
|
||||
<p class="muted-copy">{{.Data.Email}} · {{.Data.Role}}</p>
|
||||
<form class="stack-form" action="/account/password" method="post">
|
||||
<label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label>
|
||||
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
|
||||
<button class="button button-primary" type="submit">Update password</button>
|
||||
</form>
|
||||
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -8,6 +8,7 @@
|
||||
<h1 id="admin-title">Admin overview</h1>
|
||||
</div>
|
||||
<form action="/admin/logout" method="post">
|
||||
<a class="button button-outline" href="/admin/users">Users</a>
|
||||
<button class="button button-outline" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -54,6 +55,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Box</th>
|
||||
<th>Owner</th>
|
||||
<th>Files</th>
|
||||
<th>Size</th>
|
||||
<th>Downloads</th>
|
||||
@@ -67,6 +69,7 @@
|
||||
{{range .Data.Boxes}}
|
||||
<tr>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{.Owner}}</td>
|
||||
<td>{{.FileCount}}</td>
|
||||
<td>{{.TotalSizeLabel}}</td>
|
||||
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
|
||||
@@ -84,7 +87,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="8">No uploads yet.</td></tr>
|
||||
<tr><td colspan="9">No uploads yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
67
backend/templates/pages/admin_users.html
Normal file
67
backend/templates/pages/admin_users.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{{define "admin_users.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="admin-view" aria-labelledby="admin-users-title">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<p class="kicker">Operator console</p>
|
||||
<h1 id="admin-users-title">Users</h1>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<a class="button button-outline" href="/admin">Overview</a>
|
||||
<a class="button button-outline" href="/admin/files">Files</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Create invite</h2>
|
||||
<p>Copy the generated link and send it manually. SMTP delivery comes later.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Data.LastInviteURL}}
|
||||
<p class="manage-link"><span>Invite link:</span> <a href="{{.Data.LastInviteURL}}">{{.Data.LastInviteURL}}</a></p>
|
||||
{{end}}
|
||||
<form class="inline-controls" action="/admin/invites" method="post">
|
||||
<label><span>Email</span><input type="email" name="email" required></label>
|
||||
<label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label>
|
||||
<button class="button button-primary" type="submit">Create invite</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Users}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{.Role}}</td>
|
||||
<td><span class="badge">{{.Status}}</span></td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td class="table-actions">
|
||||
{{if eq .Status "disabled"}}
|
||||
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"><button class="button button-outline" type="submit">Reactivate</button></form>
|
||||
{{else}}
|
||||
<form action="/admin/users/{{.ID}}/disable" method="post"><button class="button button-danger" type="submit">Disable</button></form>
|
||||
{{end}}
|
||||
<form action="/admin/users/{{.ID}}/reset" method="post"><button class="button button-outline" type="submit">Reset link</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6">No users yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
43
backend/templates/pages/auth.html
Normal file
43
backend/templates/pages/auth.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "auth.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="auth-view" aria-labelledby="auth-title">
|
||||
<div class="card auth-card">
|
||||
<div class="card-content">
|
||||
{{if eq .Data.Mode "register"}}
|
||||
<p class="kicker">Instance bootstrap</p>
|
||||
<h1 id="auth-title">Create the admin account</h1>
|
||||
<p class="muted-copy">The first user becomes the instance admin. Registration closes after this account is created.</p>
|
||||
<form class="stack-form" action="/register" method="post">
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
<label><span>Username</span><input name="username" autocomplete="username" required></label>
|
||||
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
|
||||
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
|
||||
<button class="button button-primary" type="submit">Create admin</button>
|
||||
</form>
|
||||
{{else if eq .Data.Mode "invite"}}
|
||||
<p class="kicker">{{if .Data.IsReset}}Password reset{{else}}Invite{{end}}</p>
|
||||
<h1 id="auth-title">{{if .Data.IsReset}}Choose a new password{{else}}Create your account{{end}}</h1>
|
||||
{{if .Data.Email}}<p class="muted-copy">{{.Data.Email}}</p>{{end}}
|
||||
<form class="stack-form" action="/invite/{{.Data.Token}}" method="post">
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
{{if not .Data.IsReset}}<label><span>Username</span><input name="username" autocomplete="username" required></label>{{end}}
|
||||
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
|
||||
<button class="button button-primary" type="submit">Accept invite</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="kicker">Account</p>
|
||||
<h1 id="auth-title">Sign in</h1>
|
||||
<form class="stack-form" action="/login" method="post">
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
<input type="hidden" name="next" value="{{.Data.ReturnPath}}">
|
||||
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
|
||||
<label><span>Password</span><input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<button class="button button-primary" type="submit">Sign in</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<p class="auth-alt"><a href="/">Back to upload</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
70
backend/templates/pages/dashboard.html
Normal file
70
backend/templates/pages/dashboard.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{{define "dashboard.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell" aria-labelledby="dashboard-title">
|
||||
<aside class="app-sidebar">
|
||||
<a class="sidebar-link is-active" href="/app">Dashboard</a>
|
||||
<a class="sidebar-link" href="/account/settings">Settings</a>
|
||||
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
|
||||
<form class="collection-create" action="/app/collections" method="post">
|
||||
<label>
|
||||
<span>New collection</span>
|
||||
<input name="name" placeholder="Projects">
|
||||
</label>
|
||||
<button class="button button-outline" type="submit">Create</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<p class="kicker">Personal space</p>
|
||||
<h1 id="dashboard-title">My files</h1>
|
||||
<p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p>
|
||||
</div>
|
||||
<a class="button button-primary" href="/">Upload files</a>
|
||||
</div>
|
||||
|
||||
<div class="collection-tabs">
|
||||
<a class="button {{if not .Data.Selected}}button-primary{{else}}button-outline{{end}}" href="/app">All</a>
|
||||
{{range .Data.Collections}}
|
||||
<a class="button {{if eq $.Data.Selected .ID}}button-primary{{else}}button-outline{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header"><h2>Owned boxes</h2><p>Collections organize boxes. Shared links remain unlisted.</p></div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Title</th><th>Collection</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Boxes}}
|
||||
<tr>
|
||||
<td class="file-name">{{.Title}}</td>
|
||||
<td>{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}}</td>
|
||||
<td>{{.FileCount}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.ExpiresAt}}</td>
|
||||
<td class="table-actions">
|
||||
<a class="button button-outline" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<form action="/app/boxes/{{.ID}}/rename" method="post"><input class="compact-input" name="title" placeholder="Rename"><button class="button button-outline" type="submit">Save</button></form>
|
||||
<form action="/app/boxes/{{.ID}}/move" method="post">
|
||||
<select name="collection_id"><option value="">Unsorted</option>{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select>
|
||||
<button class="button button-outline" type="submit">Move</button>
|
||||
</form>
|
||||
<form action="/app/boxes/{{.ID}}/delete" method="post"><button class="button button-danger" type="submit">Delete</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">You have no boxes yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -15,7 +15,7 @@
|
||||
</span>
|
||||
<span class="drop-title">Drop files to upload</span>
|
||||
<span class="drop-copy">or click to browse</span>
|
||||
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · Links expire in 7 days</span>
|
||||
<span class="drop-meta">{{if .Data.IsAdmin}}Admin upload: no file size limit{{else}}Max file size: {{.Data.MaxUploadSize}}{{end}} · Links expire in 7 days</span>
|
||||
<input id="file-input" name="file" type="file" multiple>
|
||||
</label>
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
Advanced options
|
||||
</summary>
|
||||
<div class="option-grid">
|
||||
{{if .CurrentUser}}
|
||||
<label>
|
||||
<span>Collection</span>
|
||||
<select name="collection_id">
|
||||
<option value="">Unsorted</option>
|
||||
{{range .Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{end}}
|
||||
<label>
|
||||
<span>Expires in</span>
|
||||
<select name="max_days">
|
||||
|
||||
Reference in New Issue
Block a user