accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -9,8 +9,10 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/tim/cairnquire/apps/server/internal/auth"
@@ -23,6 +25,17 @@ import (
"github.com/tim/cairnquire/apps/server/internal/sync"
)
type testEmailSender struct {
body string
to []string
}
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
s.to = append([]string(nil), to...)
s.body = body
return nil
}
type apiTestServer struct {
handler http.Handler
auth *auth.Service
@@ -36,6 +49,10 @@ func newAPITestServer(t *testing.T) *apiTestServer {
}
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
}
func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
t.Helper()
ctx := context.Background()
@@ -88,7 +105,8 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
SourceDir: sourceDir,
StoreDir: storeDir,
},
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
DevMode: devMode,
},
Logger: logger,
Documents: docService,
@@ -112,6 +130,93 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
}
}
func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
server := newAPITestServerWithSetupAndDevMode(t, true, true)
devMe := httptest.NewRecorder()
server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
}
logout := httptest.NewRecorder()
server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
if logout.Code != http.StatusOK {
t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
}
devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
if devLogoutCookie.Value != "1" {
t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
}
loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
loggedOutMeRequest.AddCookie(devLogoutCookie)
loggedOutMe := httptest.NewRecorder()
server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
if loggedOutMe.Code != http.StatusUnauthorized {
t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
}
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
loginPageRequest.AddCookie(devLogoutCookie)
loginPage := httptest.NewRecorder()
server.handler.ServeHTTP(loginPage, loginPageRequest)
if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
}
devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
devLoginRequest.AddCookie(devLogoutCookie)
devLogin := httptest.NewRecorder()
server.handler.ServeHTTP(devLogin, devLoginRequest)
if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
}
clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
if clearedByDevLogin.MaxAge != -1 {
t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
}
devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
devTokenRequest.AddCookie(devLogoutCookie)
devTokenRequest.Header.Set("Authorization", "Bearer dev")
devToken := httptest.NewRecorder()
server.handler.ServeHTTP(devToken, devTokenRequest)
if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
}
loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
})
loginRequest.AddCookie(devLogoutCookie)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, loginRequest)
if login.Code != http.StatusOK {
t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
}
sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
if sessionCookie.Value == "" {
t.Fatalf("session cookie = %#v, want value", sessionCookie)
}
clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
if clearedDevLogoutCookie.MaxAge != -1 {
t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
}
}
func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
t.Helper()
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
return nil
}
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
server := newAPITestServerWithSetup(t, false)
@@ -160,6 +265,123 @@ func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
}
}
func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
server := newAPITestServer(t)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
}))
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
}
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
accountRequest.AddCookie(session)
account := httptest.NewRecorder()
server.handler.ServeHTTP(account, accountRequest)
if account.Code != http.StatusOK {
t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
}
if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
t.Fatal("account page should not render the token creation form")
}
if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
t.Fatal("account page should link to the token creation page")
}
createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
createRequest.AddCookie(session)
create := httptest.NewRecorder()
server.handler.ServeHTTP(create, createRequest)
if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
}
}
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
server := newAPITestServer(t)
sender := &testEmailSender{}
server.auth.SetEmailSender(sender)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
}))
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
}
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
"users": []map[string]any{{
"id": server.userID,
"role": "admin",
"disabled": false,
}},
})
updateRequest.AddCookie(session)
update := httptest.NewRecorder()
server.handler.ServeHTTP(update, updateRequest)
if update.Code != http.StatusOK {
t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
}
sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
sendRequest.AddCookie(session)
send := httptest.NewRecorder()
server.handler.ServeHTTP(send, sendRequest)
if send.Code != http.StatusOK {
t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
}
token := passwordResetTokenFromEmail(t, sender.body)
page := httptest.NewRecorder()
server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
}
reset := httptest.NewRecorder()
server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
"token": token,
"newPassword": "new very secure password",
}))
if reset.Code != http.StatusOK {
t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
}
newLogin := httptest.NewRecorder()
server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "new very secure password",
}))
if newLogin.Code != http.StatusOK {
t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
}
}
func passwordResetTokenFromEmail(t *testing.T, body string) string {
t.Helper()
for _, line := range strings.Split(body, "\n") {
if !strings.HasPrefix(line, "http") {
continue
}
parsed, err := url.Parse(line)
if err != nil {
t.Fatalf("parse password reset URL: %v", err)
}
if token := parsed.Query().Get("token"); token != "" {
return token
}
}
t.Fatalf("password reset body does not contain a link: %q", body)
return ""
}
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()
@@ -415,6 +637,74 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
}
}
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatalf("create nested source dir: %v", err)
}
if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
t.Fatalf("write nested source fixture: %v", err)
}
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync nested source fixture: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
if strings.Contains(documents.Body.String(), "guide/setup.md") {
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
}
}
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
ctx := context.Background()
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
t.Fatalf("write special source fixture: %v", err)
}
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync special source fixture: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
if strings.Contains(documents.Body.String(), "@dani.md") {
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
}
}
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper()

View File

@@ -57,6 +57,11 @@ type changePasswordRequest struct {
NewPassword string `json:"newPassword"`
}
type passwordResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
type deleteAccountRequest struct {
CurrentPassword string `json:"currentPassword"`
}
@@ -79,14 +84,15 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
return
}
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in",
BodyClass: "page-auth",
BodyTemplate: "login_content",
Data: struct {
Next string
SignupsEnabled bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled},
DevMode bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode},
})
}
@@ -100,13 +106,24 @@ func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, http.StatusOK, "setup.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{
Title: "Initial setup",
BodyClass: "page-auth",
BodyTemplate: "setup_content",
})
}
func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) {
s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{
Title: "Reset password",
BodyClass: "page-auth",
BodyTemplate: "password_reset_content",
Data: struct {
Token string
}{Token: strings.TrimSpace(r.URL.Query().Get("token"))},
})
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
@@ -135,7 +152,7 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
Title: "Account",
BodyClass: "page-account",
BodyTemplate: "account_content",
@@ -143,6 +160,20 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{
Title: "Create access token",
BodyClass: "page-account",
BodyTemplate: "token_create_content",
Data: principal,
})
}
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
@@ -193,9 +224,26 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
}
clearSessionCookie(w)
if s.config.DevMode {
setDevLogoutCookie(w, r)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
if !s.config.DevMode || s.devUserID == "" {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"})
return
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
clearDevLogoutCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
@@ -213,6 +261,22 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
}
func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
}
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
@@ -425,7 +489,7 @@ func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device",
BodyClass: "page-auth",
BodyTemplate: "device_verify_content",
@@ -464,6 +528,36 @@ func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var req struct {
Users []auth.UserAccessUpdate `json:"users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
public := make([]map[string]any, 0, len(users))
for _, user := range users {
public = append(public, publicUser(user))
}
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"})
}
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
if s.authLimiter.Allow(authRateLimitKey(r)) {
return true
@@ -473,6 +567,7 @@ func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
clearDevLogoutCookie(w)
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
@@ -484,6 +579,29 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
})
}
func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "1",
Path: "/",
MaxAge: int((24 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
})
}
func clearDevLogoutCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
@@ -501,6 +619,7 @@ func publicUser(user auth.User) map[string]any {
"email": user.Email,
"displayName": user.DisplayName,
"role": user.Role,
"disabled": user.Disabled,
"createdAt": user.CreatedAt,
"lastSeenAt": user.LastSeenAt,
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
@@ -29,6 +30,7 @@ type layoutData struct {
BodyClass string
BodyTemplate string
Data any
Principal *auth.Principal
}
type indexData struct {
@@ -43,6 +45,7 @@ type documentData struct {
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
CanWrite bool
}
type documentEditData struct {
@@ -53,6 +56,7 @@ type documentEditData struct {
Hash string
Source string
Breadcrumbs []breadcrumb
CanWrite bool
}
type breadcrumb struct {
@@ -98,7 +102,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
BodyClass: "page-index",
BodyTemplate: "index_content",
@@ -117,7 +121,7 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
s.logger.Warn("search failed", "error", err)
}
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
BodyClass: "page-search",
BodyTemplate: "search_content",
@@ -223,7 +227,9 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
return
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
@@ -235,6 +241,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
Hash: page.Hash,
Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
},
})
}
@@ -256,7 +263,9 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
return
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
BodyClass: "page-document",
BodyTemplate: "document_content",
@@ -268,11 +277,20 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
},
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.handleReadyz(w, r)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
@@ -286,11 +304,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
contentStatus = err.Error()
}
writeJSON(w, http.StatusOK, map[string]string{
ready := dbStatus == "ok" && contentStatus == "ok"
payload := map[string]string{
"status": "ok",
"database": dbStatus,
"contentStore": contentStatus,
})
}
if !ready {
payload["status"] = "unavailable"
writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
return
}
writeJSON(w, http.StatusOK, payload)
}
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
@@ -515,7 +540,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
}
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
if data.Principal == nil {
if principal, ok := principalFromContext(r.Context()); ok {
p := principal
data.Principal = &p
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
@@ -524,7 +555,7 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
}
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{
s.renderTemplate(w, r, status, "error.gohtml", layoutData{
Title: http.StatusText(status),
BodyClass: "page-error",
BodyTemplate: "error_content",
@@ -649,8 +680,30 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
}
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
return
}
switch {
case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"):
s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
default:
s.handleDocumentSavePath(w, r, pagePath)
}
}
func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" {
return "", nil
}
return url.PathUnescape(path)
}
func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
@@ -705,6 +758,50 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload)
}
func (s *Server) canWriteDocuments(r *http.Request) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
return auth.Allows(principal, auth.ScopeDocsWrite)
}
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
}
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
}
func trimEditSuffix(path string) (string, bool) {
if path == "edit" {
return "", true

View File

@@ -414,3 +414,135 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func TestHandleHealthzReturnsOK(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
}
func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
if payload["database"] != "ok" {
t.Fatalf("database = %q, want ok", payload["database"])
}
if payload["contentStore"] != "ok" {
t.Fatalf("contentStore = %q, want ok", payload["contentStore"])
}
}
func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) {
server := setupHealthTestServer(t)
server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist")
recorder := httptest.NewRecorder()
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "unavailable" {
t.Fatalf("status = %q, want unavailable", payload["status"])
}
if payload["contentStore"] == "ok" {
t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"])
}
}
func TestHandleHealthAliasForReadyz(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
}
func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) {
server := newAPITestServer(t)
for _, path := range []string{"/healthz", "/readyz", "/health"} {
t.Run(path, func(t *testing.T) {
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
})
}
}
func setupHealthTestServer(t *testing.T) *Server {
t.Helper()
root := t.TempDir()
storeDir := filepath.Join(root, "files")
if err := os.MkdirAll(storeDir, 0o755); err != nil {
t.Fatalf("create store dir: %v", err)
}
db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
repository := docs.NewRepository(db)
documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default())
return &Server{
config: config.Config{
Content: config.ContentConfig{StoreDir: storeDir},
},
documents: documents,
repository: repository,
contentStore: contentStore,
}
}

View File

@@ -13,6 +13,8 @@ import (
"github.com/tim/cairnquire/apps/server/internal/auth"
)
const devLogoutCookieName = "cairnquire_dev_logout"
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
timeoutMiddleware := middleware.Timeout(timeout)
return func(next http.Handler) http.Handler {
@@ -99,6 +101,8 @@ func isSetupAllowedPath(path string) bool {
return path == "/setup" ||
path == "/api/setup" ||
path == "/health" ||
path == "/healthz" ||
path == "/readyz" ||
path == "/sw.js" ||
strings.HasPrefix(path, "/static/")
}
@@ -125,7 +129,7 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
}
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil || cookie.Value == "" {
if s.devUserID == "" {
if s.devUserID == "" || hasDevLogoutCookie(r) {
return auth.Principal{}, false
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
@@ -138,6 +142,11 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
return principal, true
}
func hasDevLogoutCookie(r *http.Request) bool {
cookie, err := r.Cookie(devLogoutCookieName)
return err == nil && cookie.Value == "1"
}
func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
path := r.URL.Path
method := r.Method

View File

@@ -104,15 +104,21 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/", server.handleIndex)
router.Get("/setup", server.handleSetupPage)
router.Get("/login", server.handleLoginPage)
router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage)
router.Get("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz)
router.Get("/readyz", server.handleReadyz)
router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket)
router.Get("/api/auth/me", server.handleAuthMe)
router.Post("/api/setup", server.handleSetup)
router.Post("/api/auth/register/password", server.handlePasswordRegister)
router.Post("/api/auth/login/password", server.handlePasswordLogin)
router.Post("/api/auth/login/dev", server.handleDevLogin)
router.Post("/api/auth/password/reset", server.handlePasswordReset)
router.Post("/api/auth/logout", server.handleLogout)
router.Post("/api/auth/password/change", server.handleChangePassword)
router.Delete("/api/auth/account", server.handleDeleteAccount)
@@ -132,6 +138,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/admin/users", server.handleAdminCreateUser)
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
router.Patch("/api/admin/auth-users", server.handleAdminUpdateAuthUsers)
router.Post("/api/admin/auth-users/{id}/password-reset", server.handleAdminSendPasswordReset)
router.Get("/api/admin/settings", server.handleAdminSettings)
router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate)
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
@@ -139,7 +147,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument)
router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentSave)
router.Post("/api/documents/*", server.handleDocumentMutation)
router.Get("/api/search", server.handleSearch)
router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)

View File

@@ -0,0 +1,45 @@
(function () {
'use strict';
function apiDocumentPath(path) {
return (path || '')
.replace(/^\/+/, '')
.split('/')
.map(encodeURIComponent)
.join('/');
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-archive-path]');
if (!btn) return;
var path = btn.getAttribute('data-archive-path');
if (!path) return;
if (!confirm('Archive this document? It will be hidden from the site but can be restored later.')) {
return;
}
btn.disabled = true;
btn.textContent = 'Archiving...';
fetch('/api/documents/' + apiDocumentPath(path) + '/archive', {
method: 'POST',
credentials: 'same-origin',
})
.then(function (res) {
if (res.ok) {
window.location.href = '/';
return;
}
return res.json().then(function (data) {
throw new Error(data.error || 'Archive failed');
});
})
.catch(function (err) {
alert(err.message || 'Failed to archive document');
btn.disabled = false;
btn.textContent = 'Archive';
});
});
})();

View File

@@ -34,10 +34,14 @@
const loginSection = document.querySelector('[data-login-section]');
const registerCancel = document.querySelector('[data-register-cancel]');
const authHeading = document.querySelector('[data-auth-heading]');
if (registerToggle && registerPanel && loginSection) {
registerToggle.addEventListener('click', () => {
loginSection.hidden = true;
registerPanel.hidden = false;
if (authHeading) authHeading.textContent = 'Create an Account';
document.title = 'Create an Account';
});
}
@@ -45,6 +49,8 @@
registerCancel.addEventListener('click', () => {
registerPanel.hidden = true;
loginSection.hidden = false;
if (authHeading) authHeading.textContent = 'Log In';
document.title = 'Sign in';
// Clear any registration form status messages
document.querySelector('[data-passkey-register-status]').textContent = '';
document.querySelector('[data-register-status]').textContent = '';
@@ -152,6 +158,15 @@
}
});
document.querySelector("[data-dev-login]")?.addEventListener("click", async () => {
try {
await postJSON("/api/auth/login/dev", {});
window.location.href = authNext();
} catch (error) {
setStatus("[data-dev-login-status]", error.message, true);
}
});
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
@@ -237,19 +252,37 @@
});
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
await postJSON("/api/auth/logout", {});
window.location.href = "/login";
try {
await postJSON("/api/auth/logout", {});
} catch (error) {
console.warn("Logout request failed:", error);
} finally {
window.location.href = "/login";
}
});
const loadTokens = async () => {
const list = document.querySelector("[data-token-list]");
if (!list) return;
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
if (!data.tokens?.length) {
const empty = document.createElement("li");
empty.className = "token-empty";
empty.textContent = "No access tokens yet. Create one when you connect the macOS app, CLI, or another trusted client.";
list.replaceChildren(empty);
return;
}
list.replaceChildren(
...(data.tokens || []).map((token) => {
...data.tokens.map((token) => {
const item = document.createElement("li");
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
const meta = document.createElement("span");
meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
meta.className = "token-meta";
const name = document.createElement("strong");
name.textContent = token.name;
const details = document.createElement("small");
details.textContent = `${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
meta.append(name, details);
const button = document.createElement("button");
button.type = "button";
button.textContent = "Revoke";
@@ -297,6 +330,17 @@
}
});
document.querySelector("[data-password-reset]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/auth/password/reset", formJSON(event.currentTarget));
setStatus("[data-password-reset-status]", "Password reset. You can sign in with your new password.");
event.currentTarget.querySelector('input[name="newPassword"]').value = "";
} catch (error) {
setStatus("[data-password-reset-status]", error.message, true);
}
});
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
event.preventDefault();
if (!confirm("Delete this account permanently?")) return;
@@ -324,7 +368,7 @@
const loadAdminUsers = async () => {
const section = document.querySelector("[data-admin-users-section]");
const list = document.querySelector("[data-admin-users]");
const list = document.querySelector("[data-admin-user-list]");
if (!section || !list) return;
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
if (me?.principal?.role !== "admin") return;
@@ -332,8 +376,9 @@
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
list.replaceChildren(
...(data.users || []).map((user) => {
const row = document.createElement("form");
const row = document.createElement("div");
row.className = "user-row";
row.dataset.userId = user.id;
const identity = document.createElement("span");
const name = document.createElement("strong");
name.textContent = user.displayName;
@@ -348,24 +393,52 @@
option.selected = user.role === role;
select.append(option);
}
const button = document.createElement("button");
button.type = "submit";
button.textContent = "Save";
row.append(identity, select, button);
row.addEventListener("submit", async (event) => {
event.preventDefault();
select.name = "role";
select.setAttribute("aria-label", `Role for ${user.displayName}`);
const disabledLabel = document.createElement("label");
disabledLabel.className = "user-disable-toggle";
const disabled = document.createElement("input");
disabled.type = "checkbox";
disabled.name = "disabled";
disabled.checked = Boolean(user.disabled);
const disabledText = document.createElement("span");
disabledText.textContent = "Disabled";
disabledLabel.append(disabled, disabledText);
const reset = document.createElement("button");
reset.type = "button";
reset.textContent = "Send password reset";
reset.addEventListener("click", async () => {
if (!confirm(`Send a new password reset email to ${user.email}? Any earlier reset link will stop working.`)) return;
try {
await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" });
setStatus("[data-admin-users-status]", "Role updated.");
await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {});
setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`);
} catch (error) {
setStatus("[data-admin-users-status]", error.message, true);
}
});
row.append(identity, select, disabledLabel, reset);
return row;
}),
);
};
document.querySelector("[data-admin-users]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const users = [...form.querySelectorAll("[data-user-id]")].map((row) => ({
id: row.dataset.userId,
role: row.querySelector('select[name="role"]').value,
disabled: row.querySelector('input[name="disabled"]').checked,
}));
try {
await postJSON("/api/admin/auth-users", { users }, { method: "PATCH" });
setStatus("[data-admin-users-status]", "User access settings saved.");
await loadAdminUsers();
} catch (error) {
setStatus("[data-admin-users-status]", error.message, true);
}
});
const loadAdminSettings = async () => {
const section = document.querySelector("[data-admin-settings-section]");
const form = document.querySelector("[data-admin-settings]");

View File

@@ -4,20 +4,28 @@
const bell = document.querySelector('[data-notification-bell]');
if (!bell) return;
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
let isOpen = false;
bell.addEventListener('click', () => {
trigger.addEventListener('click', (e) => {
e.stopPropagation();
isOpen = !isOpen;
dropdown.hidden = !isOpen;
trigger.setAttribute('aria-expanded', String(isOpen));
if (isOpen) loadNotifications();
});
document.addEventListener('click', (e) => {
if (!bell.contains(e.target)) {
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
if (isOpen) {
isOpen = false;
dropdown.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
}
});
@@ -51,8 +59,17 @@
if (!list) return;
list.innerHTML = '';
// Header
const header = document.createElement('li');
header.className = 'notification-header';
header.textContent = 'Notifications';
list.appendChild(header);
if (!notifications.length) {
list.innerHTML = '<li class="notification-empty">No notifications</li>';
const empty = document.createElement('li');
empty.className = 'notification-empty';
empty.textContent = 'No notifications';
list.appendChild(empty);
return;
}

View File

@@ -129,6 +129,143 @@ code {
display: block;
}
/* Notification bell */
.notification-bell {
position: relative;
}
.notification-bell__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
transition: background 0.15s;
}
.notification-bell__trigger:hover {
background: rgba(0, 0, 0, 0.06);
}
.notification-bell__trigger svg {
display: block;
}
.notification-bell__count {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #dc2626;
color: white;
font-size: 0.65rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notification-bell__dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 20;
width: 320px;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0;
background: var(--panel-strong);
box-shadow: var(--shadow);
}
.notification-bell__dropdown[hidden] {
display: none;
}
.notification-list {
margin: 0;
padding: 0;
list-style: none;
}
.notification-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.1s;
}
.notification-item:hover {
background: var(--accent-soft);
}
.notification-item.is-read {
opacity: 0.7;
cursor: default;
}
.notification-message {
font-size: 0.9rem;
line-height: 1.4;
color: var(--text);
}
.notification-time {
display: block;
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--muted);
}
.notification-header {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.notification-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--muted);
font-size: 0.9rem;
}
.notification-footer {
padding: 0.5rem 1rem;
text-align: center;
}
.notification-footer button {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.notification-footer button:hover {
color: var(--text);
border-color: var(--muted);
background: var(--panel);
}
.site-search {
position: relative;
display: flex;
@@ -150,13 +287,18 @@ code {
width: 100%;
padding: 0.4rem 0.75rem 0.4rem 2rem;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
border-radius: 0;
background: var(--panel);
color: var(--text);
font: inherit;
font-size: 0.825rem;
transition: border-color 0.15s, background 0.15s;
}
.site-search input::placeholder {
color: var(--muted);
}
.site-search input:focus {
outline: none;
border-color: var(--accent);
@@ -376,6 +518,16 @@ code {
display: block;
}
.auth-dev-login {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.auth-dev-login .btn-secondary {
width: 100%;
}
/* Register CTA */
.auth-register-cta {
margin-top: 1.5rem;
@@ -456,31 +608,282 @@ code {
align-items: center;
justify-content: space-between;
gap: 1rem;
width: min(100%, 1120px);
width: min(100%, 1180px);
margin: 0 auto 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel);
box-shadow: var(--shadow);
}
.account-header p {
margin: 0.35rem 0 0;
.account-identity {
display: flex;
min-width: 0;
align-items: center;
gap: 0.9rem;
}
.account-avatar {
display: inline-flex;
width: 3.4rem;
height: 3.4rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border));
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
}
.account-header h1 {
margin-top: 0.15rem;
font-size: clamp(1.7rem, 3vw, 2.4rem);
}
.account-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
margin-top: 0.45rem;
color: var(--muted);
font-size: 0.9rem;
}
.account-grid {
width: min(100%, 1120px);
.account-role {
padding: 0.18rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace;
text-transform: uppercase;
}
.account-header .account-signout {
display: inline-flex;
min-height: 2.45rem;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.75rem;
border-color: var(--border);
background: var(--panel-strong);
color: var(--text);
box-shadow: none;
font-size: 0.9rem;
}
.account-header .account-signout:hover {
border-color: var(--muted);
background: var(--accent-soft);
}
.account-layout {
display: grid;
width: min(100%, 1180px);
margin: 0 auto;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
gap: 1rem;
align-items: start;
}
.account-main,
.account-sidebar {
display: grid;
gap: 1rem;
}
.account-section {
padding: 1rem;
width: 100%;
padding: clamp(1rem, 2vw, 1.35rem);
border-radius: var(--radius-md);
}
.account-section__header {
display: grid;
gap: 0.45rem;
margin-bottom: 1rem;
}
.account-section__header--action {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
}
.account-section__header h2,
.account-list-heading h3 {
margin: 0;
}
.account-section__header h2 {
font-size: 1.2rem;
}
.account-section__header p,
.account-list-heading p {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.account-section__kicker {
margin: 0 0 0.25rem !important;
color: var(--accent) !important;
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace !important;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.account-section__divider {
height: 1px;
margin: 1.25rem 0 1rem;
background: var(--border);
}
.account-list-heading {
display: grid;
gap: 0.25rem;
}
.account-list-heading h3 {
font-size: 1rem;
}
.account-action,
.account-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
}
.account-action {
min-height: 2.5rem;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
border-radius: var(--radius-sm);
background: var(--accent);
color: white;
font-weight: 600;
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.account-action:hover {
background: color-mix(in srgb, var(--accent) 85%, black);
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent);
}
.account-action:active {
transform: translateY(1px);
box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent);
}
.account-page-header,
.token-create-layout {
width: min(100%, 920px);
margin-right: auto;
margin-left: auto;
}
.account-page-header {
margin-bottom: 1rem;
padding: clamp(1rem, 2vw, 1.35rem);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel);
box-shadow: var(--shadow);
}
.account-page-header h1 {
margin: 0.35rem 0 0;
font-size: clamp(1.8rem, 3vw, 2.5rem);
}
.account-page-header p:last-child {
margin: 0.5rem 0 0;
color: var(--muted);
}
.account-back {
width: fit-content;
margin-bottom: 1.1rem;
color: var(--muted);
font-size: 0.9rem;
}
.account-back:hover {
color: var(--accent);
}
.token-create-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(15rem, 18rem);
gap: 1rem;
align-items: start;
}
.token-create-note h2 {
margin: 0;
font-size: 1.05rem;
}
.token-create-note p:last-child {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}
.account-shell .auth-form button[type="submit"] {
min-height: 2.5rem;
padding: 0.6rem 0.85rem;
font-size: 0.95rem;
}
.account-form-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.account-form-footer .form-status {
flex: 1 1 14rem;
}
.account-section--danger {
border-color: color-mix(in srgb, #b42318 40%, var(--border));
background: color-mix(in srgb, #b42318 4%, var(--panel));
}
.account-section--danger .account-section__kicker {
color: #b42318 !important;
}
.account-shell .account-danger-button[type="submit"] {
border-color: color-mix(in srgb, #b42318 70%, var(--border));
background: #b42318;
box-shadow: 0 4px 12px color-mix(in srgb, #b42318 22%, transparent);
}
.account-shell .account-danger-button[type="submit"]:hover {
background: #8f1c13;
}
.scope-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
margin: 0;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 3%, var(--panel-strong));
}
.scope-grid legend {
@@ -494,11 +897,47 @@ code {
gap: 0.4rem;
}
.scope-grid input {
width: auto;
}
.auth-form .auth-choice.account-toggle {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong);
}
.auth-form .account-toggle input {
width: auto;
margin: 0.1rem 0 0;
}
.account-toggle span {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.account-toggle strong {
color: var(--text);
}
.account-toggle small {
color: var(--muted);
line-height: 1.35;
}
.token-output {
max-width: 100%;
overflow: auto;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -521,14 +960,40 @@ code {
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong);
}
.user-row {
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto;
.token-list {
margin-top: 0.75rem;
}
.user-row span {
.token-meta {
display: grid;
gap: 0.18rem;
}
.token-meta small {
color: var(--muted);
line-height: 1.35;
}
.token-row.is-revoked {
opacity: 0.64;
}
.token-empty {
display: block !important;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.user-row {
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto auto;
}
.user-row > span {
display: grid;
gap: 0.15rem;
}
@@ -537,6 +1002,84 @@ code {
color: var(--muted);
}
.user-management {
display: grid;
gap: 1rem;
}
.user-management .account-form-footer {
margin-top: 0.25rem;
}
.user-management button[type="submit"] {
min-height: 2.5rem;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
border-radius: var(--radius-sm);
background: var(--accent);
color: white;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.user-disable-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.user-disable-toggle input {
width: auto;
}
@media (max-width: 880px) {
.account-layout {
grid-template-columns: 1fr;
}
.token-create-layout {
grid-template-columns: 1fr;
}
.account-sidebar {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
align-items: start;
}
}
@media (max-width: 560px) {
.account-shell {
padding: 0.75rem;
}
.account-header {
align-items: flex-start;
padding: 0.9rem;
}
.account-avatar {
width: 2.8rem;
height: 2.8rem;
}
.account-header .account-signout {
min-height: 2.2rem;
padding: 0.45rem;
}
.account-signout svg {
display: none;
}
.user-row {
grid-template-columns: 1fr;
}
}
/* Workspace layout - adaptive grid */
.workspace-shell {
display: grid;
@@ -1166,6 +1709,12 @@ code {
align-items: end;
}
.document-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.document-edit-link {
display: inline-flex;
align-items: center;
@@ -1178,6 +1727,25 @@ code {
background: var(--panel);
}
.document-archive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.4rem;
padding: 0 0.9rem;
border: 1px solid color-mix(in srgb, #b42318 40%, var(--border));
background: transparent;
color: #b42318;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.document-archive-btn:hover {
background: color-mix(in srgb, #b42318 8%, transparent);
}
.document-shell--editor {
display: grid;
grid-template-rows: auto auto 1fr;
@@ -1595,6 +2163,10 @@ code {
background: oklch(0.16 0.014 55 / 0.92);
}
.notification-bell__trigger:hover {
background: rgba(255, 255, 255, 0.08);
}
.miller-column h2 {
background: oklch(0.20 0.015 55 / 0.72);
}
@@ -1826,4 +2398,3 @@ code {
font-size: 0.9rem;
line-height: 1.5;
}

View File

@@ -3,82 +3,130 @@
{{ define "account_content" }}
<section class="account-shell">
<header class="account-header">
<div>
<p class="eyebrow">Account</p>
<h1>{{ .DisplayName }}</h1>
<p>{{ .Email }} &middot; {{ .Role }}</p>
<div class="account-identity">
<span class="account-avatar" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.5" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
</svg>
</span>
<div>
<p class="eyebrow">Account center</p>
<h1>{{ .DisplayName }}</h1>
<div class="account-meta">
<span>{{ .Email }}</span>
<span class="account-role">{{ .Role }}</span>
</div>
</div>
</div>
<button type="button" data-auth-logout>Sign out</button>
<button class="account-signout" type="button" data-auth-logout>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10 17l5-5-5-5" />
<path d="M15 12H3" />
<path d="M21 19V5a2 2 0 0 0-2-2h-6" />
</svg>
Sign out
</button>
</header>
<div class="account-grid">
<section class="account-section">
<h2>API Tokens</h2>
<form class="auth-form" data-token-create>
<label>
Name
<input type="text" name="name" placeholder="CLI on laptop" required />
</label>
<fieldset class="scope-grid">
<legend>Scopes</legend>
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
</fieldset>
<button type="submit">Create token</button>
<p class="form-status" data-token-status></p>
<pre class="token-output" data-token-output hidden></pre>
</form>
<ul class="token-list" data-token-list></ul>
</section>
<div class="account-layout">
<div class="account-main">
<section class="account-section account-section--tokens">
<header class="account-section__header account-section__header--action">
<div>
<p class="account-section__kicker">Integrations</p>
<h2>Access tokens</h2>
<p>Manage access for the macOS app, CLI, or another trusted client.</p>
</div>
<a class="account-action" href="/account/tokens/new">Create token</a>
</header>
<div class="account-list-heading">
<h3>Issued tokens</h3>
<p>Tokens can be revoked immediately. New secrets are shown only once.</p>
</div>
<ul class="token-list" data-token-list></ul>
</section>
<section class="account-section">
<h2>Password</h2>
<form class="auth-form" data-password-change>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit">Change password</button>
<p class="form-status" data-password-status></p>
</form>
</section>
<section class="account-section account-section--users" data-admin-users-section hidden>
<header class="account-section__header">
<div>
<p class="account-section__kicker">Administration</p>
<h2>People and roles</h2>
</div>
<p>Control access levels for everyone with an account on this server.</p>
</header>
<form class="user-management" data-admin-users>
<div class="user-list" data-admin-user-list></div>
<div class="account-form-footer">
<button type="submit">Save changes</button>
<p class="form-status" data-admin-users-status></p>
</div>
</form>
</section>
</div>
<section class="account-section" data-admin-users-section hidden>
<h2>Users</h2>
<div class="user-list" data-admin-users></div>
<p class="form-status" data-admin-users-status></p>
</section>
<aside class="account-sidebar">
<section class="account-section">
<header class="account-section__header">
<div>
<p class="account-section__kicker">Security</p>
<h2>Change password</h2>
</div>
<p>Use at least 12 characters for your new password.</p>
</header>
<form class="auth-form" data-password-change>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit">Update password</button>
<p class="form-status" data-password-status></p>
</form>
</section>
<section class="account-section" data-admin-settings-section hidden>
<h2>Registration</h2>
<form class="auth-form" data-admin-settings>
<label class="auth-choice">
<input type="checkbox" name="signupsEnabled" />
Allow visitors to create accounts
</label>
<button type="submit">Save registration setting</button>
<p class="form-status" data-admin-settings-status></p>
</form>
</section>
<section class="account-section" data-admin-settings-section hidden>
<header class="account-section__header">
<div>
<p class="account-section__kicker">Administration</p>
<h2>Registration</h2>
</div>
<p>Choose whether new visitors can create their own accounts.</p>
</header>
<form class="auth-form" data-admin-settings>
<label class="auth-choice account-toggle">
<input type="checkbox" name="signupsEnabled" />
<span>
<strong>Public signups</strong>
<small>Allow visitors to register without an invitation.</small>
</span>
</label>
<button type="submit">Save setting</button>
<p class="form-status" data-admin-settings-status></p>
</form>
</section>
<section class="account-section account-section--danger">
<h2>Delete Account</h2>
<form class="auth-form" data-account-delete>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<button type="submit">Delete account</button>
<p class="form-status" data-delete-status></p>
</form>
</section>
<section class="account-section account-section--danger">
<header class="account-section__header">
<div>
<p class="account-section__kicker">Danger zone</p>
<h2>Delete account</h2>
</div>
<p>This permanently removes your account and cannot be undone.</p>
</header>
<form class="auth-form" data-account-delete>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<button class="account-danger-button" type="submit">Delete account</button>
<p class="form-status" data-delete-status></p>
</form>
</section>
</aside>
</div>
</section>
{{ end }}

View File

@@ -27,6 +27,20 @@
<input type="search" name="q" placeholder="Search documents…" aria-label="Search documents" />
</form>
<nav class="site-nav">
{{ if .Principal }}
<div class="notification-bell" data-notification-bell>
<button class="notification-bell__trigger" type="button" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<span class="notification-bell__count" data-unread-count hidden>0</span>
</button>
<div class="notification-bell__dropdown" data-notification-dropdown role="menu" hidden>
<ul class="notification-list" aria-live="polite"></ul>
</div>
</div>
{{ end }}
<a href="/help" aria-label="Help" title="Help">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</a>
@@ -52,8 +66,12 @@
{{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }}
{{ template "setup_content" .Data }}
{{ else if eq .BodyTemplate "password_reset_content" }}
{{ template "password_reset_content" .Data }}
{{ else if eq .BodyTemplate "account_content" }}
{{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "token_create_content" }}
{{ template "token_create_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }}
{{ template "device_verify_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
@@ -79,7 +97,7 @@
<ul data-sync-queue-list></ul>
</section>
<div class="version-notice" data-version-notice hidden>
<p>A newer version is available.</p>
<p>Document changes are available.</p>
<button type="button" data-version-reload>Reload</button>
</div>
<script src="/static/cache.js" defer></script>
@@ -89,6 +107,7 @@
<script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script>
<script src="/static/comments.js" defer></script>
<script src="/static/archive.js" defer></script>
</body>
</html>

View File

@@ -21,7 +21,12 @@
<div class="document-meta">
<div class="document-meta__header">
<h1>{{ .Title }}</h1>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
{{ if .CanWrite }}
<div class="document-actions">
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
</div>
{{ end }}
</div>
<details class="document-meta-panel">
<summary>

View File

@@ -6,7 +6,7 @@
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1>Log In</h1>
<h1 data-auth-heading>Log In</h1>
</div>
<div class="auth-login-section" data-login-section>
@@ -43,6 +43,12 @@
</div>
</div>
{{ if .DevMode }}<div class="auth-dev-login">
<p class="auth-note">Development mode is enabled. Use the local development admin without a password.</p>
<button type="button" class="btn-secondary" data-dev-login>Continue as development admin</button>
<p class="form-status" data-dev-login-status></p>
</div>{{ end }}
{{ if .SignupsEnabled }}<div class="auth-register-cta">
<button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
</div>{{ end }}

View File

@@ -0,0 +1,23 @@
{{ define "password_reset.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "password_reset_content" }}
<section class="auth-shell">
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1>Reset password</h1>
<p class="auth-note">Choose a new password with at least 12 characters.</p>
</div>
<form class="auth-form" data-password-reset>
<input type="hidden" name="token" value="{{ .Token }}" />
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit" class="btn-primary">Set new password</button>
<p class="form-status" data-password-reset-status></p>
</form>
</div>
</section>
{{ end }}

View File

@@ -0,0 +1,49 @@
{{ define "token_create.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "token_create_content" }}
<section class="account-shell">
<header class="account-page-header">
<a class="account-back" href="/account">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</svg>
Back to account
</a>
<p class="account-section__kicker">Integrations</p>
<h1>Create access token</h1>
<p>Give a trusted client the smallest set of permissions it needs.</p>
</header>
<div class="token-create-layout">
<section class="account-section">
<form class="auth-form token-create-form" data-token-create>
<label>
Token name
<input type="text" name="name" placeholder="MacBook sync app" required />
</label>
<fieldset class="scope-grid">
<legend>Permissions</legend>
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
</fieldset>
<div class="account-form-footer">
<button type="submit">Create token</button>
<a class="btn-secondary" href="/account">Cancel</a>
</div>
<p class="form-status" data-token-status></p>
<pre class="token-output" data-token-output hidden></pre>
</form>
</section>
<aside class="account-section token-create-note">
<p class="account-section__kicker">Before you create</p>
<h2>Keep the secret somewhere safe</h2>
<p>The token is shown once after creation. Store it in the client that needs access, then return to your account to revoke it later.</p>
</aside>
</div>
</section>
{{ end }}