rin
Public Open-source lightweight git hosting.

feat: profile media, account deletion, registration modes and admin controls

07bc77b Browse files
R rin · 2026-10-17 06:16 UTC
25 files changed +1616 -107
README.md
+10 -2
... ... @@ -14,6 +14,8 @@ Polar is a small, fast alternative to heavyweight Git servers: push over HTTP, b
14 14 - **Command palette** — press `Ctrl+K` / `Cmd+K` for quick navigation and actions.
15 15 - **Three themes** — Light (`#fff` background, `#111` foreground), Dark (`#111` background, `#fff` foreground), and System (follows the OS `prefers-color-scheme`). The choice is persisted in a `polar_theme` cookie so the server renders the correct `data-theme` on first paint — no flash of the wrong theme.
16 16 - **Bilingual interface** — Russian and English, with a configurable default language.
17 +- **Profiles and repository identity** — avatars, profile READMEs, and full-width background images with a soft vignette in both light and dark themes. Repositories can use their own avatar.
18 +- **Administration** — public, confirm-only, or closed registration; an approval queue; role management; and permanent deletion of accounts and repositories (including all on-disk data).
17 19 - **Practical security** — Argon2id password hashing, CSRF protection, rate-limited login and registration, session cookies, and revocable access tokens.
18 20
19 21 ## Quick Start
... ... @@ -27,7 +29,7 @@ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o polar ./cmd/polar
27 29 ./polar
28 30 ```
29 31
30 -Polar starts on `0.0.0.0:3000` and keeps its data in `./data`. Open http://localhost:3000 and register — **the first registered account becomes the administrator**. After that you can disable public registration or manage users from the admin panel.
32 +Polar starts on `0.0.0.0:3000` and keeps its data in `./data`. Open http://localhost:3000 and register — **the first registered account becomes the administrator**. After that, choose **Public Registration**, **Confirm-only Registration**, or **No Registration** in the admin panel. Confirm-only mode keeps new accounts pending until an administrator approves them.
31 33
32 34 ### Docker
33 35
... ... @@ -90,7 +92,7 @@ default_lang = "en" # "en" or "ru"
90 92 | `POLAR_SERVER_PORT` | HTTP listen port | `3000` |
91 93 | `POLAR_SERVER_BASE_URL` | Public URL used for clone links | `http://localhost:3000` |
92 94 | `POLAR_STORAGE_DATA_DIR` | Directory for the database and bare repositories | `./data` |
93 -| `POLAR_AUTH_REGISTRATION_ENABLED` | Allow public registration | `true` |
95 +| `POLAR_AUTH_REGISTRATION_ENABLED` | Initial registration policy before the admin panel setting is stored (`false` means closed) | `true` |
94 96 | `POLAR_LANDING_ENABLED` | Serve the landing page at `/` | `true` |
95 97 | `POLAR_LANDING_DEFAULT_LANG` | Default UI language (`ru` or `en`) | `ru` |
96 98 | `POLAR_CONFIG` | Path to the configuration file | `polar.toml` if present |
... ... @@ -146,6 +148,12 @@ Device codes expire after 15 minutes, are single-use, and the issued token can b
146 148
147 149 **Reviews.** On an open pull request, reviewers submit an **Approve** or **Request changes** verdict — reviewing your own pull request is not allowed. Each reviewer has one current verdict, and the review panel lists them all.
148 150
151 +## Accounts and Administration
152 +
153 +Administrators can switch registration between **Public Registration**, **Confirm-only Registration**, and **No Registration** at any time. Confirm-only accounts cannot sign in until approved from the admin panel.
154 +
155 +Deleting an account from **Settings → Delete account** or from the admin panel permanently removes the user, all repositories they own, uploaded release assets, sessions, and repository files on disk. The last administrator account is protected from deletion. Repository deletion through the admin panel follows the same irreversible path.
156 +
149 157 ## Themes
150 158
151 159 Polar ships exactly three themes, selectable from the theme switcher (or the command palette):
internal/db/db.go
+54 -1
... ... @@ -78,6 +78,17 @@ func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, erro
78 78 }
79 79 }
80 80
81 + if _, err := d.GetSetting(pingCtx, "registration_mode"); errors.Is(err, sql.ErrNoRows) {
82 + mode := RegistrationModeClosed
83 + if d.IsRegistrationEnabled(pingCtx, defaultRegEnabled) {
84 + mode = RegistrationModePublic
85 + }
86 + if err := d.SetSetting(pingCtx, "registration_mode", mode); err != nil {
87 + _ = sqlDB.Close()
88 + return nil, fmt.Errorf("seed registration_mode setting: %w", err)
89 + }
90 + }
91 +
81 92 return d, nil
82 93 }
83 94
... ... @@ -181,12 +192,54 @@ func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error
181 192 return d.SetSetting(ctx, key, strconv.FormatBool(enabled))
182 193 }
183 194
195 +const (
196 + RegistrationModePublic = "public"
197 + RegistrationModeConfirm = "confirm"
198 + RegistrationModeClosed = "closed"
199 +)
200 +
201 +func ValidRegistrationMode(mode string) bool {
202 + switch mode {
203 + case RegistrationModePublic, RegistrationModeConfirm, RegistrationModeClosed:
204 + return true
205 + default:
206 + return false
207 + }
208 +}
209 +
210 +// GetRegistrationMode returns the current registration policy. Older databases
211 +// only have the boolean registration_enabled setting, so fall back to it.
212 +func (d *DB) GetRegistrationMode(ctx context.Context, fallback bool) string {
213 + mode, err := d.GetSetting(ctx, "registration_mode")
214 + if err == nil && ValidRegistrationMode(mode) {
215 + return mode
216 + }
217 + if d.IsRegistrationEnabled(ctx, fallback) {
218 + return RegistrationModePublic
219 + }
220 + return RegistrationModeClosed
221 +}
222 +
223 +func (d *DB) SetRegistrationMode(ctx context.Context, mode string) error {
224 + if !ValidRegistrationMode(mode) {
225 + return fmt.Errorf("invalid registration mode %q", mode)
226 + }
227 + if err := d.SetSetting(ctx, "registration_mode", mode); err != nil {
228 + return err
229 + }
230 + return d.SetBoolSetting(ctx, "registration_enabled", mode != RegistrationModeClosed)
231 +}
232 +
184 233 func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool {
185 234 return d.GetBoolSetting(ctx, "registration_enabled", fallback)
186 235 }
187 236
188 237 func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error {
189 - return d.SetBoolSetting(ctx, "registration_enabled", enabled)
238 + mode := RegistrationModeClosed
239 + if enabled {
240 + mode = RegistrationModePublic
241 + }
242 + return d.SetRegistrationMode(ctx, mode)
190 243 }
191 244
192 245 func (d *DB) IsExploreEnabled(ctx context.Context) bool {
internal/db/migrations/006_media_and_registration.sql
+9 -0
... ... @@ -0,0 +1,9 @@
1 +ALTER TABLE users ADD COLUMN background_url TEXT NOT NULL DEFAULT '';
2 +ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1;
3 +ALTER TABLE repositories ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';
4 +
5 +INSERT INTO settings (key, value)
6 +SELECT 'registration_mode',
7 + CASE WHEN LOWER(value) IN ('1', 'true', 'on') THEN 'public' ELSE 'closed' END
8 +FROM settings
9 +WHERE key = 'registration_enabled';
internal/i18n/i18n.go
+124 -2
... ... @@ -533,7 +533,68 @@ var translations = map[Lang]map[string]string{
533 533 "repo.updated": "Обновлён",
534 534 "repo.view_file": "Открыть",
535 535 "err.collab_forbidden": "Управлять коллабораторами может только владелец или администратор.",
536 - "err.pr_head_repo_invalid": "Выбран недопустимый репозиторий-источник."},
536 + "err.pr_head_repo_invalid": "Выбран недопустимый репозиторий-источник.",
537 +
538 + // Media, account deletion & administration
539 + "auth.pending_approval": "Аккаунт ожидает подтверждения администратора.",
540 + "auth.register.pending_notice": "После регистрации аккаунт должен подтвердить администратор.",
541 + "settings.background": "Фон профиля",
542 + "settings.background_upload": "Выбрать фон",
543 + "settings.background_remove": "Убрать фон",
544 + "settings.background_hint": "PNG, JPG, WebP или GIF. Изображение мягко растворяется по краям.",
545 + "settings.delete_account": "Удаление аккаунта",
546 + "settings.delete_account_desc": "Аккаунт и все его репозитории будут удалены без возможности восстановления.",
547 + "settings.delete_account_confirm": "Введите имя пользователя",
548 + "settings.delete_password": "Текущий пароль",
549 + "settings.delete_account_button": "Удалить аккаунт навсегда",
550 + "settings.delete_mismatch": "Имя пользователя не совпадает.",
551 + "settings.delete_last_admin": "Нельзя удалить последнего администратора.",
552 + "repo.avatar": "Аватар репозитория",
553 + "repo.avatar_upload": "Выбрать изображение",
554 + "repo.avatar_remove": "Удалить аватар",
555 + "repo.avatar_hint": "Квадратное изображение до 2 МБ. Без аватара используется изображение владельца.",
556 + "admin.registration.public": "Публичная регистрация",
557 + "admin.registration.confirm": "Регистрация с подтверждением",
558 + "admin.registration.closed": "Без регистрации",
559 + "admin.registration.public_desc": "Аккаунт активируется сразу.",
560 + "admin.registration.confirm_desc": "Аккаунт активируется после одобрения администратором.",
561 + "admin.registration.closed_desc": "Самостоятельная регистрация новых аккаунтов закрыта.",
562 + "admin.registration.mode": "Новые аккаунты",
563 + "admin.repos": "Репозитории",
564 + "admin.col.status": "Статус",
565 + "admin.col.actions": "Действия",
566 + "admin.pending": "Ожидает",
567 + "admin.approved": "Подтверждён",
568 + "admin.approve": "Подтвердить",
569 + "admin.make_admin": "Сделать админом",
570 + "admin.remove_admin": "Снять админа",
571 + "admin.delete_user": "Удалить",
572 + "admin.delete_repo": "Удалить",
573 + "admin.repo_visibility": "Доступ",
574 + "admin.delete_user_title": "Удалить аккаунт",
575 + "admin.delete_user_desc": "Все репозитории и данные пользователя будут удалены без возможности восстановления.",
576 + "admin.delete_repo_title": "Удалить репозиторий",
577 + "admin.delete_repo_desc": "Git-данные, релизы и файлы будут удалены без возможности восстановления.",
578 + "admin.confirm_user": "Введите имя пользователя",
579 + "admin.confirm_repo": "Введите название репозитория",
580 + "flash.image_invalid": "Изображение слишком большое или имеет неподдерживаемый формат.",
581 + "flash.image_save_failed": "Не удалось сохранить изображение. Попробуйте ещё раз.",
582 + "flash.account_deleted": "Аккаунт и все его репозитории удалены.",
583 + "flash.account_delete_failed": "Не удалось удалить аккаунт.",
584 + "flash.admin_user_approved": "Аккаунт @%s подтверждён.",
585 + "flash.admin_user_missing": "Пользователь не найден.",
586 + "flash.admin_user_update_failed": "Не удалось обновить пользователя.",
587 + "flash.admin_self_role": "Нельзя менять собственную роль через админку.",
588 + "flash.admin_last_admin": "Нельзя удалить или понизить последнего администратора.",
589 + "flash.admin_role_updated": "Роль @%s обновлена.",
590 + "flash.admin_delete_self": "Удалить собственный аккаунт можно только в настройках профиля.",
591 + "flash.admin_delete_mismatch": "Подтверждение не совпадает.",
592 + "flash.admin_user_delete_failed": "Не удалось удалить пользователя.",
593 + "flash.admin_user_deleted": "Пользователь @%s и его репозитории удалены.",
594 + "flash.admin_repo_missing": "Репозиторий не найден.",
595 + "flash.admin_repo_delete_failed": "Не удалось удалить репозиторий.",
596 + "flash.admin_repo_deleted": "Репозиторий %s/%s удалён.",
597 + "flash.admin_registration_invalid": "Некорректный режим регистрации."},
537 598 EN: {
538 599 // Navigation & Common
539 600 "app.tagline": "Lightweight self-hosted Git hosting",
... ... @@ -1051,7 +1112,68 @@ var translations = map[Lang]map[string]string{
1051 1112 "repo.updated": "Updated",
1052 1113 "repo.view_file": "Open",
1053 1114 "err.collab_forbidden": "Only the owner or an administrator can manage collaborators.",
1054 - "err.pr_head_repo_invalid": "The selected source repository is not allowed."},
1115 + "err.pr_head_repo_invalid": "The selected source repository is not allowed.",
1116 +
1117 + // Media, account deletion & administration
1118 + "auth.pending_approval": "This account is waiting for administrator approval.",
1119 + "auth.register.pending_notice": "An administrator must approve the account after sign-up.",
1120 + "settings.background": "Profile background",
1121 + "settings.background_upload": "Choose background",
1122 + "settings.background_remove": "Remove background",
1123 + "settings.background_hint": "PNG, JPG, WebP, or GIF. The image fades gently at the edges.",
1124 + "settings.delete_account": "Delete account",
1125 + "settings.delete_account_desc": "The account and all of its repositories will be permanently deleted.",
1126 + "settings.delete_account_confirm": "Type your username to confirm",
1127 + "settings.delete_password": "Current password",
1128 + "settings.delete_account_button": "Delete account permanently",
1129 + "settings.delete_mismatch": "The username does not match.",
1130 + "settings.delete_last_admin": "The last administrator account cannot be deleted.",
1131 + "repo.avatar": "Repository avatar",
1132 + "repo.avatar_upload": "Choose image",
1133 + "repo.avatar_remove": "Remove avatar",
1134 + "repo.avatar_hint": "A square image up to 2 MB. The owner avatar is used when empty.",
1135 + "admin.registration.public": "Public Registration",
1136 + "admin.registration.confirm": "Confirm-only Registration",
1137 + "admin.registration.closed": "No Registration",
1138 + "admin.registration.public_desc": "Accounts become active immediately.",
1139 + "admin.registration.confirm_desc": "Accounts become active after administrator approval.",
1140 + "admin.registration.closed_desc": "New accounts cannot be created by visitors.",
1141 + "admin.registration.mode": "New accounts",
1142 + "admin.repos": "Repositories",
1143 + "admin.col.status": "Status",
1144 + "admin.col.actions": "Actions",
1145 + "admin.pending": "Pending",
1146 + "admin.approved": "Approved",
1147 + "admin.approve": "Approve",
1148 + "admin.make_admin": "Make admin",
1149 + "admin.remove_admin": "Remove admin",
1150 + "admin.delete_user": "Delete",
1151 + "admin.delete_repo": "Delete",
1152 + "admin.repo_visibility": "Access",
1153 + "admin.delete_user_title": "Delete account",
1154 + "admin.delete_user_desc": "All repositories and user data will be permanently deleted.",
1155 + "admin.delete_repo_title": "Delete repository",
1156 + "admin.delete_repo_desc": "Git data, releases, and uploaded files will be permanently deleted.",
1157 + "admin.confirm_user": "Type the username",
1158 + "admin.confirm_repo": "Type the repository name",
1159 + "flash.image_invalid": "The image is too large or uses an unsupported format.",
1160 + "flash.image_save_failed": "The image could not be saved. Please try again.",
1161 + "flash.account_deleted": "The account and all of its repositories were deleted.",
1162 + "flash.account_delete_failed": "Could not delete the account.",
1163 + "flash.admin_user_approved": "Account @%s approved.",
1164 + "flash.admin_user_missing": "User not found.",
1165 + "flash.admin_user_update_failed": "Could not update the user.",
1166 + "flash.admin_self_role": "You cannot change your own role from the admin panel.",
1167 + "flash.admin_last_admin": "The last administrator cannot be removed or demoted.",
1168 + "flash.admin_role_updated": "The role for @%s was updated.",
1169 + "flash.admin_delete_self": "Delete your own account from profile settings.",
1170 + "flash.admin_delete_mismatch": "The confirmation does not match.",
1171 + "flash.admin_user_delete_failed": "Could not delete the user.",
1172 + "flash.admin_user_deleted": "User @%s and their repositories were deleted.",
1173 + "flash.admin_repo_missing": "Repository not found.",
1174 + "flash.admin_repo_delete_failed": "Could not delete the repository.",
1175 + "flash.admin_repo_deleted": "Repository %s/%s deleted.",
1176 + "flash.admin_registration_invalid": "Invalid registration mode."},
1055 1177 }
1056 1178
1057 1179 func T(lang Lang, key string) string {
internal/repo/repo.go
+48 -3
... ... @@ -39,6 +39,7 @@ type Repository struct {
39 39 OwnerAvatarURL string
40 40 Name string
41 41 Description string
42 + AvatarURL string
42 43 IsPrivate bool
43 44 DefaultBranch string
44 45 EnableIssues bool
... ... @@ -180,7 +181,7 @@ func (s *Service) Create(ctx context.Context, owner *user.User, name, descriptio
180 181 }
181 182
182 183 const repoSelectFields = `
183 - SELECT r.id, r.owner_id, u.username, u.avatar_url, r.name, r.description, r.is_private, r.default_branch,
184 + SELECT r.id, r.owner_id, u.username, u.avatar_url, r.avatar_url, r.name, r.description, r.is_private, r.default_branch,
184 185 r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity,
185 186 r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name,
186 187 (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count,
... ... @@ -334,6 +335,18 @@ func (s *Service) Update(ctx context.Context, r *Repository, description string,
334 335 return nil
335 336 }
336 337
338 +func (s *Service) UpdateAvatar(ctx context.Context, repoID int64, avatarURL string) error {
339 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
340 + defer cancel()
341 + _, err := s.db.ExecContext(cctx, `
342 + UPDATE repositories SET avatar_url = ?, updated_at = ? WHERE id = ?
343 + `, strings.TrimSpace(avatarURL), time.Now().UTC(), repoID)
344 + if err != nil {
345 + return fmt.Errorf("update repository avatar: %w", err)
346 + }
347 + return nil
348 +}
349 +
337 350 func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) {
338 351 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
339 352 defer cancel()
... ... @@ -411,6 +424,38 @@ func (s *Service) Delete(ctx context.Context, r *Repository) error {
411 424 return nil
412 425 }
413 426
427 +// DeleteAllByOwner removes every repository owned by the user, including bare
428 +// Git data and uploaded release assets, then removes the owner's empty storage
429 +// directory. It is used when an account is deleted.
430 +func (s *Service) DeleteAllByOwner(ctx context.Context, owner *user.User) error {
431 + if owner == nil {
432 + return ErrRepoNotFound
433 + }
434 + repos, err := s.ListByOwner(ctx, owner.ID, true)
435 + if err != nil {
436 + return err
437 + }
438 +
439 + var errs []error
440 + for i := range repos {
441 + if err := s.Delete(ctx, &repos[i]); err != nil {
442 + errs = append(errs, fmt.Errorf("delete %s/%s: %w", repos[i].OwnerUsername, repos[i].Name, err))
443 + }
444 + }
445 +
446 + baseAbs, err := filepath.Abs(s.reposDir)
447 + if err != nil {
448 + return fmt.Errorf("resolve repos dir: %w", err)
449 + }
450 + ownerDir := filepath.Join(baseAbs, strings.ToLower(owner.Username))
451 + if len(errs) == 0 {
452 + if err := os.RemoveAll(ownerDir); err != nil {
453 + errs = append(errs, fmt.Errorf("remove owner repository directory: %w", err))
454 + }
455 + }
456 + return errors.Join(errs...)
457 +}
458 +
414 459 func boolInt(b bool) int {
415 460 if b {
416 461 return 1
... ... @@ -535,7 +580,7 @@ func scanRepo(row *sql.Row) (*Repository, error) {
535 580 var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
536 581 var forkedFromID sql.NullInt64
537 582 var forkedFromOwner, forkedFromName sql.NullString
538 - err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
583 + err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
539 584 &enableIssues, &enablePulls, &enableReleases, &enableActivity,
540 585 &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
541 586 &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
... ... @@ -567,7 +612,7 @@ func scanRepos(rows *sql.Rows) ([]Repository, error) {
567 612 var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
568 613 var forkedFromID sql.NullInt64
569 614 var forkedFromOwner, forkedFromName sql.NullString
570 - if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
615 + if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
571 616 &enableIssues, &enablePulls, &enableReleases, &enableActivity,
572 617 &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
573 618 &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
internal/user/user.go
+133 -25
... ... @@ -17,7 +17,9 @@ import (
17 17 var (
18 18 ErrUserNotFound = errors.New("user not found")
19 19 ErrUserAlreadyExists = errors.New("username or email already taken")
20 - ErrRegistrationDisabled = errors.New("public registration is disabled")
20 + ErrRegistrationDisabled = errors.New("registration is disabled")
21 + ErrPendingApproval = errors.New("account is awaiting administrator approval")
22 + ErrLastAdmin = errors.New("the last administrator account cannot be removed")
21 23 ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores")
22 24 ErrReservedUsername = errors.New("this username is reserved by the system")
23 25 ErrInvalidEmail = errors.New("invalid email address")
... ... @@ -38,18 +40,20 @@ var reservedUsernames = map[string]struct{}{
38 40 }
39 41
40 42 type User struct {
41 - ID int64
42 - Username string
43 - Email string
44 - PasswordHash string
45 - FullName string
46 - Bio string
47 - AvatarURL string
48 - Location string
49 - Website string
50 - IsAdmin bool
51 - RepoCount int
52 - CreatedAt time.Time
43 + ID int64
44 + Username string
45 + Email string
46 + PasswordHash string
47 + FullName string
48 + Bio string
49 + AvatarURL string
50 + BackgroundURL string
51 + Location string
52 + Website string
53 + IsAdmin bool
54 + IsApproved bool
55 + RepoCount int
56 + CreatedAt time.Time
53 57 }
54 58
55 59 type Service struct {
... ... @@ -98,9 +102,24 @@ func (s *Service) Count(ctx context.Context) (int, error) {
98 102 }
99 103
100 104 func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) {
105 + mode := db.RegistrationModeClosed
106 + if regEnabled {
107 + mode = db.RegistrationModePublic
108 + }
109 + return s.RegisterWithMode(ctx, username, email, password, mode)
110 +}
111 +
112 +// RegisterWithMode creates an account according to the server registration
113 +// policy. "public" accounts are usable immediately, while "confirm" accounts
114 +// stay pending until an administrator approves them. The first account is
115 +// always an approved administrator, regardless of the policy.
116 +func (s *Service) RegisterWithMode(ctx context.Context, username, email, password, mode string) (*User, error) {
101 117 username = strings.TrimSpace(username)
102 118 email = strings.ToLower(strings.TrimSpace(email))
103 119
120 + if !db.ValidRegistrationMode(mode) {
121 + return nil, fmt.Errorf("invalid registration mode %q", mode)
122 + }
104 123 if err := ValidateUsername(username); err != nil {
105 124 return nil, err
106 125 }
... ... @@ -130,7 +149,7 @@ func (s *Service) Register(ctx context.Context, username, email, password string
130 149 return nil, fmt.Errorf("count existing users: %w", err)
131 150 }
132 151
133 - if userCount > 0 && !regEnabled {
152 + if userCount > 0 && mode == db.RegistrationModeClosed {
134 153 return nil, ErrRegistrationDisabled
135 154 }
136 155
... ... @@ -149,12 +168,17 @@ func (s *Service) Register(ctx context.Context, username, email, password string
149 168 if isAdmin {
150 169 isAdminInt = 1
151 170 }
171 + isApproved := isAdmin || mode == db.RegistrationModePublic
172 + isApprovedInt := 0
173 + if isApproved {
174 + isApprovedInt = 1
175 + }
152 176 now := time.Now().UTC()
153 177
154 178 res, err := tx.ExecContext(cctx, `
155 - INSERT INTO users (username, email, password_hash, bio, is_admin, created_at)
156 - VALUES (?, ?, ?, '', ?, ?)
157 - `, username, email, hash, isAdminInt, now)
179 + INSERT INTO users (username, email, password_hash, bio, is_admin, is_approved, created_at)
180 + VALUES (?, ?, ?, '', ?, ?, ?)
181 + `, username, email, hash, isAdminInt, isApprovedInt, now)
158 182 if err != nil {
159 183 if strings.Contains(strings.ToLower(err.Error()), "unique") {
160 184 return nil, ErrUserAlreadyExists
... ... @@ -173,6 +197,7 @@ func (s *Service) Register(ctx context.Context, username, email, password string
173 197 Email: email,
174 198 PasswordHash: hash,
175 199 IsAdmin: isAdmin,
200 + IsApproved: isApproved,
176 201 CreatedAt: now,
177 202 }, nil
178 203 }
... ... @@ -187,7 +212,7 @@ func (s *Service) Authenticate(ctx context.Context, login, password string) (*Us
187 212 defer cancel()
188 213
189 214 u, err := s.scanUser(s.db.QueryRowContext(cctx, `
190 - SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
215 + SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at
191 216 FROM users
192 217 WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
193 218 `, login, login))
... ... @@ -202,6 +227,9 @@ func (s *Service) Authenticate(ctx context.Context, login, password string) (*Us
202 227 if err != nil || !ok {
203 228 return nil, auth.ErrInvalidCredentials
204 229 }
230 + if !u.IsApproved {
231 + return nil, ErrPendingApproval
232 + }
205 233 return u, nil
206 234 }
207 235
... ... @@ -210,7 +238,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
210 238 defer cancel()
211 239
212 240 return s.scanUser(s.db.QueryRowContext(cctx, `
213 - SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
241 + SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at
214 242 FROM users WHERE id = ?
215 243 `, id))
216 244 }
... ... @@ -220,7 +248,7 @@ func (s *Service) GetByUsername(ctx context.Context, username string) (*User, er
220 248 defer cancel()
221 249
222 250 return s.scanUser(s.db.QueryRowContext(cctx, `
223 - SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
251 + SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at
224 252 FROM users WHERE LOWER(username) = LOWER(?)
225 253 `, strings.TrimSpace(username)))
226 254 }
... ... @@ -287,6 +315,19 @@ func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL stri
287 315 return nil
288 316 }
289 317
318 +func (s *Service) UpdateBackground(ctx context.Context, userID int64, backgroundURL string) error {
319 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
320 + defer cancel()
321 +
322 + _, err := s.db.ExecContext(cctx, `
323 + UPDATE users SET background_url = ? WHERE id = ?
324 + `, strings.TrimSpace(backgroundURL), userID)
325 + if err != nil {
326 + return fmt.Errorf("update profile background: %w", err)
327 + }
328 + return nil
329 +}
330 +
290 331 func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error {
291 332 if len(newPassword) < 8 {
292 333 return ErrWeakPassword
... ... @@ -319,7 +360,7 @@ func (s *Service) ListAll(ctx context.Context) ([]User, error) {
319 360 defer cancel()
320 361
321 362 rows, err := s.db.QueryContext(cctx, `
322 - SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.location, u.website, u.is_admin, u.created_at,
363 + SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.background_url, u.location, u.website, u.is_admin, u.is_approved, u.created_at,
323 364 COUNT(r.id) AS repo_count
324 365 FROM users u
325 366 LEFT JOIN repositories r ON r.owner_id = u.id
... ... @@ -334,17 +375,44 @@ func (s *Service) ListAll(ctx context.Context) ([]User, error) {
334 375 var users []User
335 376 for rows.Next() {
336 377 var u User
337 - var isAdmin int
338 - if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt, &u.RepoCount); err != nil {
378 + var isAdmin, isApproved int
379 + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.BackgroundURL, &u.Location, &u.Website, &isAdmin, &isApproved, &u.CreatedAt, &u.RepoCount); err != nil {
339 380 return nil, fmt.Errorf("scan user: %w", err)
340 381 }
341 382 u.IsAdmin = isAdmin == 1
383 + u.IsApproved = isApproved == 1
342 384 users = append(users, u)
343 385 }
344 386 return users, rows.Err()
345 387 }
346 388
389 +func (s *Service) CountAdmins(ctx context.Context) (int, error) {
390 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
391 + defer cancel()
392 + var count int
393 + if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&count); err != nil {
394 + return 0, fmt.Errorf("count administrators: %w", err)
395 + }
396 + return count, nil
397 +}
398 +
347 399 func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error {
400 + if !isAdmin {
401 + u, err := s.GetByID(ctx, userID)
402 + if err != nil {
403 + return err
404 + }
405 + if u.IsAdmin {
406 + count, err := s.CountAdmins(ctx)
407 + if err != nil {
408 + return err
409 + }
410 + if count <= 1 {
411 + return ErrLastAdmin
412 + }
413 + }
414 + }
415 +
348 416 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
349 417 defer cancel()
350 418 val := 0
... ... @@ -355,10 +423,49 @@ func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) erro
355 423 return err
356 424 }
357 425
426 +func (s *Service) SetApproved(ctx context.Context, userID int64, approved bool) error {
427 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
428 + defer cancel()
429 + val := 0
430 + if approved {
431 + val = 1
432 + }
433 + _, err := s.db.ExecContext(cctx, `UPDATE users SET is_approved = ? WHERE id = ?`, val, userID)
434 + if err != nil {
435 + return fmt.Errorf("update account approval: %w", err)
436 + }
437 + return nil
438 +}
439 +
440 +// Delete removes the user record. Callers must remove the user's repositories
441 +// and their on-disk data first. The last administrator is protected.
442 +func (s *Service) Delete(ctx context.Context, userID int64) error {
443 + u, err := s.GetByID(ctx, userID)
444 + if err != nil {
445 + return err
446 + }
447 + if u.IsAdmin {
448 + count, err := s.CountAdmins(ctx)
449 + if err != nil {
450 + return err
451 + }
452 + if count <= 1 {
453 + return ErrLastAdmin
454 + }
455 + }
456 +
457 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
458 + defer cancel()
459 + if _, err := s.db.ExecContext(cctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
460 + return fmt.Errorf("delete user: %w", err)
461 + }
462 + return nil
463 +}
464 +
358 465 func (s *Service) scanUser(row *sql.Row) (*User, error) {
359 466 var u User
360 - var isAdmin int
361 - err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt)
467 + var isAdmin, isApproved int
468 + err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.BackgroundURL, &u.Location, &u.Website, &isAdmin, &isApproved, &u.CreatedAt)
362 469 if err != nil {
363 470 if errors.Is(err, sql.ErrNoRows) {
364 471 return nil, ErrUserNotFound
... ... @@ -366,5 +473,6 @@ func (s *Service) scanUser(row *sql.Row) (*User, error) {
366 473 return nil, fmt.Errorf("scan user: %w", err)
367 474 }
368 475 u.IsAdmin = isAdmin == 1
476 + u.IsApproved = isApproved == 1
369 477 return &u, nil
370 478 }
internal/web/handlers/account.go
+54 -0
... ... @@ -0,0 +1,54 @@
1 +package handlers
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "strings"
7 +
8 + "polar/internal/user"
9 + "polar/internal/web/middleware"
10 +
11 + "github.com/gofiber/fiber/v2"
12 +)
13 +
14 +func (h *Handler) deleteAccount(ctx context.Context, account *user.User) error {
15 + if account == nil {
16 + return user.ErrUserNotFound
17 + }
18 + if err := h.repoSvc.DeleteAllByOwner(ctx, account); err != nil {
19 + return err
20 + }
21 + return h.userSvc.Delete(ctx, account.ID)
22 +}
23 +
24 +func (h *Handler) DeleteAccountSubmit(c *fiber.Ctx) error {
25 + current := middleware.CurrentUser(c)
26 + if current == nil {
27 + return c.Redirect("/login", fiber.StatusSeeOther)
28 + }
29 +
30 + confirm := strings.TrimSpace(c.FormValue("confirm_username"))
31 + password := c.FormValue("password")
32 + if !strings.EqualFold(confirm, current.Username) {
33 + h.setFlashT(c, "settings.delete_mismatch")
34 + return c.Redirect("/settings#danger", fiber.StatusSeeOther)
35 + }
36 + if _, err := h.userSvc.Authenticate(c.UserContext(), current.Username, password); err != nil {
37 + h.setFlashT(c, "auth.invalid_creds")
38 + return c.Redirect("/settings#danger", fiber.StatusSeeOther)
39 + }
40 +
41 + if err := h.deleteAccount(c.UserContext(), current); err != nil {
42 + h.logger.Error("delete account failed", "user_id", current.ID, "err", err)
43 + if errors.Is(err, user.ErrLastAdmin) {
44 + h.setFlashT(c, "settings.delete_last_admin")
45 + } else {
46 + h.setFlashT(c, "flash.account_delete_failed")
47 + }
48 + return c.Redirect("/settings#danger", fiber.StatusSeeOther)
49 + }
50 +
51 + c.ClearCookie(middleware.SessionCookieName)
52 + h.setFlashT(c, "flash.account_deleted")
53 + return c.Redirect("/", fiber.StatusSeeOther)
54 +}
internal/web/handlers/account_admin_test.go
+248 -0
... ... @@ -0,0 +1,248 @@
1 +package handlers
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "io"
7 + "net/http"
8 + "net/http/httptest"
9 + "net/url"
10 + "os"
11 + "path/filepath"
12 + "strconv"
13 + "strings"
14 + "testing"
15 +
16 + "polar/internal/auth"
17 + "polar/internal/db"
18 + "polar/internal/repo"
19 + "polar/internal/user"
20 + "polar/internal/web/middleware"
21 +)
22 +
23 +func authenticatedFormRequest(t *testing.T, method, path string, form url.Values, sess *auth.Session) *http.Request {
24 + t.Helper()
25 + req := httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
26 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
27 + req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
28 + return req
29 +}
30 +
31 +func TestRegistrationModesMediaPersistence(t *testing.T) {
32 + app, _, userSvc, repoSvc := setupTestApp(t)
33 + ctx := context.Background()
34 +
35 + admin, err := userSvc.RegisterWithMode(ctx, "admin1", "admin1@example.com", "password123", db.RegistrationModePublic)
36 + if err != nil {
37 + t.Fatalf("register first administrator: %v", err)
38 + }
39 + if !admin.IsAdmin || !admin.IsApproved {
40 + t.Fatalf("first account must be an approved administrator: %+v", admin)
41 + }
42 +
43 + pending, err := userSvc.RegisterWithMode(ctx, "pending1", "pending1@example.com", "password123", db.RegistrationModeConfirm)
44 + if err != nil {
45 + t.Fatalf("register pending account: %v", err)
46 + }
47 + if pending.IsApproved {
48 + t.Fatal("confirm-only registration must create a pending account")
49 + }
50 + if _, err := userSvc.Authenticate(ctx, pending.Username, "password123"); !errors.Is(err, user.ErrPendingApproval) {
51 + t.Fatalf("pending account login error = %v, want ErrPendingApproval", err)
52 + }
53 + if err := userSvc.SetApproved(ctx, pending.ID, true); err != nil {
54 + t.Fatalf("approve pending account: %v", err)
55 + }
56 + if _, err := userSvc.Authenticate(ctx, pending.Username, "password123"); err != nil {
57 + t.Fatalf("approved account login failed: %v", err)
58 + }
59 +
60 + member, err := userSvc.RegisterWithMode(ctx, "member1", "member1@example.com", "password123", db.RegistrationModePublic)
61 + if err != nil {
62 + t.Fatalf("register public account: %v", err)
63 + }
64 + if !member.IsApproved {
65 + t.Fatal("public registration must create an approved account")
66 + }
67 + if _, err := userSvc.RegisterWithMode(ctx, "closed1", "closed1@example.com", "password123", db.RegistrationModeClosed); !errors.Is(err, user.ErrRegistrationDisabled) {
68 + t.Fatalf("closed registration error = %v, want ErrRegistrationDisabled", err)
69 + }
70 +
71 + background := "data:image/webp;base64,AAAA"
72 + avatar := "data:image/webp;base64,BBBB"
73 + if err := userSvc.UpdateBackground(ctx, member.ID, background); err != nil {
74 + t.Fatalf("update profile background: %v", err)
75 + }
76 + if err := userSvc.UpdateAvatar(ctx, member.ID, avatar); err != nil {
77 + t.Fatalf("update profile avatar: %v", err)
78 + }
79 + storedMember, err := userSvc.GetByUsername(ctx, member.Username)
80 + if err != nil {
81 + t.Fatalf("load member: %v", err)
82 + }
83 + if storedMember.BackgroundURL != background || storedMember.AvatarURL != avatar {
84 + t.Fatalf("profile media not persisted: background=%q avatar=%q", storedMember.BackgroundURL, storedMember.AvatarURL)
85 + }
86 +
87 + repository, err := repoSvc.Create(ctx, member, "media-repo", "Media persistence", false)
88 + if err != nil {
89 + t.Fatalf("create repository: %v", err)
90 + }
91 + repoAvatar := "data:image/webp;base64,CCCC"
92 + if err := repoSvc.UpdateAvatar(ctx, repository.ID, repoAvatar); err != nil {
93 + t.Fatalf("update repository avatar: %v", err)
94 + }
95 + storedRepo, err := repoSvc.GetByID(ctx, repository.ID)
96 + if err != nil {
97 + t.Fatalf("load repository: %v", err)
98 + }
99 + if storedRepo.AvatarURL != repoAvatar {
100 + t.Fatalf("repository avatar = %q, want %q", storedRepo.AvatarURL, repoAvatar)
101 + }
102 +
103 + profileResp, err := app.Test(httptest.NewRequest(http.MethodGet, "/member1", nil), 5000)
104 + if err != nil || profileResp.StatusCode != http.StatusOK {
105 + t.Fatalf("profile response: status=%v err=%v", profileResp.StatusCode, err)
106 + }
107 + profileBody, _ := io.ReadAll(profileResp.Body)
108 + if !strings.Contains(string(profileBody), background) || !strings.Contains(string(profileBody), "profile-hero-bg") {
109 + t.Fatal("profile page must render the saved vignette background")
110 + }
111 +
112 + repoResp, err := app.Test(httptest.NewRequest(http.MethodGet, "/member1/media-repo", nil), 5000)
113 + if err != nil || repoResp.StatusCode != http.StatusOK {
114 + t.Fatalf("repository response: status=%v err=%v", repoResp.StatusCode, err)
115 + }
116 + repoBody, _ := io.ReadAll(repoResp.Body)
117 + if !strings.Contains(string(repoBody), repoAvatar) {
118 + t.Fatal("repository page must render the custom repository avatar")
119 + }
120 +}
121 +
122 +func TestAdminCanDeleteRepositoriesAndAccounts(t *testing.T) {
123 + app, authSvc, userSvc, repoSvc := setupTestApp(t)
124 + ctx := context.Background()
125 +
126 + admin, err := userSvc.RegisterWithMode(ctx, "admin2", "admin2@example.com", "password123", db.RegistrationModePublic)
127 + if err != nil {
128 + t.Fatalf("register administrator: %v", err)
129 + }
130 + member, err := userSvc.RegisterWithMode(ctx, "member2", "member2@example.com", "password123", db.RegistrationModePublic)
131 + if err != nil {
132 + t.Fatalf("register member: %v", err)
133 + }
134 + repository, err := repoSvc.Create(ctx, member, "delete-me", "Temporary repository", false)
135 + if err != nil {
136 + t.Fatalf("create repository: %v", err)
137 + }
138 +
139 + sess, err := authSvc.CreateSession(ctx, admin.ID)
140 + if err != nil {
141 + t.Fatalf("create administrator session: %v", err)
142 + }
143 +
144 + repoDeletePath := "/admin/repos/" + strconv.FormatInt(repository.ID, 10) + "/delete"
145 + req := authenticatedFormRequest(t, http.MethodPost, repoDeletePath, url.Values{
146 + "_csrf": {sess.CSRFToken},
147 + "confirm_name": {repository.Name},
148 + }, sess)
149 + resp, err := app.Test(req, 5000)
150 + if err != nil {
151 + t.Fatalf("admin repository delete request: %v", err)
152 + }
153 + if resp.StatusCode != http.StatusSeeOther {
154 + t.Fatalf("admin repository delete status = %d, want 303", resp.StatusCode)
155 + }
156 + if _, err := repoSvc.GetByID(ctx, repository.ID); !errors.Is(err, repo.ErrRepoNotFound) {
157 + t.Fatalf("deleted repository lookup error = %v, want ErrRepoNotFound", err)
158 + }
159 +
160 + remaining, err := repoSvc.Create(ctx, member, "delete-with-account", "Temporary repository", false)
161 + if err != nil {
162 + t.Fatalf("create remaining repository: %v", err)
163 + }
164 + diskPath, err := repoSvc.DiskPath(member.Username, remaining.Name)
165 + if err != nil {
166 + t.Fatalf("resolve repository path: %v", err)
167 + }
168 + ownerDir := filepath.Dir(diskPath)
169 +
170 + userDeletePath := "/admin/users/" + strconv.FormatInt(member.ID, 10) + "/delete"
171 + req = authenticatedFormRequest(t, http.MethodPost, userDeletePath, url.Values{
172 + "_csrf": {sess.CSRFToken},
173 + "confirm_username": {member.Username},
174 + }, sess)
175 + resp, err = app.Test(req, 5000)
176 + if err != nil {
177 + t.Fatalf("admin account delete request: %v", err)
178 + }
179 + if resp.StatusCode != http.StatusSeeOther {
180 + t.Fatalf("admin account delete status = %d, want 303", resp.StatusCode)
181 + }
182 + if _, err := userSvc.GetByUsername(ctx, member.Username); !errors.Is(err, user.ErrUserNotFound) {
183 + t.Fatalf("deleted account lookup error = %v, want ErrUserNotFound", err)
184 + }
185 + if _, err := repoSvc.GetByID(ctx, remaining.ID); !errors.Is(err, repo.ErrRepoNotFound) {
186 + t.Fatalf("cascaded repository lookup error = %v, want ErrRepoNotFound", err)
187 + }
188 + if _, err := os.Stat(ownerDir); !os.IsNotExist(err) {
189 + t.Fatalf("owner repository directory still exists: %v", err)
190 + }
191 +}
192 +
193 +func TestAccountSelfDeleteAndLastAdminProtection(t *testing.T) {
194 + app, authSvc, userSvc, repoSvc := setupTestApp(t)
195 + ctx := context.Background()
196 +
197 + admin, err := userSvc.RegisterWithMode(ctx, "admin3", "admin3@example.com", "password123", db.RegistrationModePublic)
198 + if err != nil {
199 + t.Fatalf("register administrator: %v", err)
200 + }
201 + member, err := userSvc.RegisterWithMode(ctx, "member3", "member3@example.com", "password123", db.RegistrationModePublic)
202 + if err != nil {
203 + t.Fatalf("register member: %v", err)
204 + }
205 + repository, err := repoSvc.Create(ctx, member, "self-delete", "Temporary repository", false)
206 + if err != nil {
207 + t.Fatalf("create repository: %v", err)
208 + }
209 +
210 + sess, err := authSvc.CreateSession(ctx, member.ID)
211 + if err != nil {
212 + t.Fatalf("create member session: %v", err)
213 + }
214 + wrongPassword := authenticatedFormRequest(t, http.MethodPost, "/settings/delete", url.Values{
215 + "_csrf": {sess.CSRFToken},
216 + "confirm_username": {member.Username},
217 + "password": {"wrong-password"},
218 + }, sess)
219 + resp, err := app.Test(wrongPassword, 5000)
220 + if err != nil || resp.StatusCode != http.StatusSeeOther {
221 + t.Fatalf("wrong-password delete response: status=%v err=%v", resp.StatusCode, err)
222 + }
223 + if _, err := userSvc.GetByUsername(ctx, member.Username); err != nil {
224 + t.Fatalf("account was deleted with a wrong password: %v", err)
225 + }
226 +
227 + deleteReq := authenticatedFormRequest(t, http.MethodPost, "/settings/delete", url.Values{
228 + "_csrf": {sess.CSRFToken},
229 + "confirm_username": {member.Username},
230 + "password": {"password123"},
231 + }, sess)
232 + resp, err = app.Test(deleteReq, 5000)
233 + if err != nil {
234 + t.Fatalf("self delete request: %v", err)
235 + }
236 + if resp.StatusCode != http.StatusSeeOther {
237 + t.Fatalf("self delete status = %d, want 303", resp.StatusCode)
238 + }
239 + if _, err := userSvc.GetByUsername(ctx, member.Username); !errors.Is(err, user.ErrUserNotFound) {
240 + t.Fatalf("self-deleted account lookup error = %v, want ErrUserNotFound", err)
241 + }
242 + if _, err := repoSvc.GetByID(ctx, repository.ID); !errors.Is(err, repo.ErrRepoNotFound) {
243 + t.Fatalf("self-deleted repository lookup error = %v, want ErrRepoNotFound", err)
244 + }
245 + if err := userSvc.Delete(ctx, admin.ID); !errors.Is(err, user.ErrLastAdmin) {
246 + t.Fatalf("last administrator deletion error = %v, want ErrLastAdmin", err)
247 + }
248 +}
internal/web/handlers/admin.go
+115 -0
... ... @@ -0,0 +1,115 @@
1 +package handlers
2 +
3 +import (
4 + "errors"
5 + "strconv"
6 + "strings"
7 +
8 + "polar/internal/user"
9 + "polar/internal/web/middleware"
10 +
11 + "github.com/gofiber/fiber/v2"
12 +)
13 +
14 +func (h *Handler) AdminApproveUserSubmit(c *fiber.Ctx) error {
15 + id, err := strconv.ParseInt(c.Params("id"), 10, 64)
16 + if err != nil {
17 + return c.Redirect("/admin", fiber.StatusSeeOther)
18 + }
19 + target, err := h.userSvc.GetByID(c.UserContext(), id)
20 + if err != nil {
21 + h.setFlashT(c, "flash.admin_user_missing")
22 + return c.Redirect("/admin", fiber.StatusSeeOther)
23 + }
24 + if err := h.userSvc.SetApproved(c.UserContext(), target.ID, true); err != nil {
25 + h.logger.Error("approve account failed", "user_id", target.ID, "err", err)
26 + h.setFlashT(c, "flash.admin_user_update_failed")
27 + return c.Redirect("/admin", fiber.StatusSeeOther)
28 + }
29 + h.setFlashT(c, "flash.admin_user_approved", target.Username)
30 + return c.Redirect("/admin", fiber.StatusSeeOther)
31 +}
32 +
33 +func (h *Handler) AdminToggleUserAdminSubmit(c *fiber.Ctx) error {
34 + id, err := strconv.ParseInt(c.Params("id"), 10, 64)
35 + if err != nil {
36 + return c.Redirect("/admin", fiber.StatusSeeOther)
37 + }
38 + current := middleware.CurrentUser(c)
39 + target, err := h.userSvc.GetByID(c.UserContext(), id)
40 + if err != nil {
41 + h.setFlashT(c, "flash.admin_user_missing")
42 + return c.Redirect("/admin", fiber.StatusSeeOther)
43 + }
44 + if current != nil && target.ID == current.ID {
45 + h.setFlashT(c, "flash.admin_self_role")
46 + return c.Redirect("/admin", fiber.StatusSeeOther)
47 + }
48 + if err := h.userSvc.SetAdmin(c.UserContext(), target.ID, !target.IsAdmin); err != nil {
49 + if errors.Is(err, user.ErrLastAdmin) {
50 + h.setFlashT(c, "flash.admin_last_admin")
51 + } else {
52 + h.logger.Error("update administrator role failed", "user_id", target.ID, "err", err)
53 + h.setFlashT(c, "flash.admin_user_update_failed")
54 + }
55 + return c.Redirect("/admin", fiber.StatusSeeOther)
56 + }
57 + h.setFlashT(c, "flash.admin_role_updated", target.Username)
58 + return c.Redirect("/admin", fiber.StatusSeeOther)
59 +}
60 +
61 +func (h *Handler) AdminDeleteUserSubmit(c *fiber.Ctx) error {
62 + id, err := strconv.ParseInt(c.Params("id"), 10, 64)
63 + if err != nil {
64 + return c.Redirect("/admin", fiber.StatusSeeOther)
65 + }
66 + current := middleware.CurrentUser(c)
67 + target, err := h.userSvc.GetByID(c.UserContext(), id)
68 + if err != nil {
69 + h.setFlashT(c, "flash.admin_user_missing")
70 + return c.Redirect("/admin", fiber.StatusSeeOther)
71 + }
72 + if current != nil && target.ID == current.ID {
73 + h.setFlashT(c, "flash.admin_delete_self")
74 + return c.Redirect("/admin", fiber.StatusSeeOther)
75 + }
76 + if !strings.EqualFold(strings.TrimSpace(c.FormValue("confirm_username")), target.Username) {
77 + h.setFlashT(c, "flash.admin_delete_mismatch")
78 + return c.Redirect("/admin", fiber.StatusSeeOther)
79 + }
80 +
81 + if err := h.deleteAccount(c.UserContext(), target); err != nil {
82 + h.logger.Error("admin delete account failed", "user_id", target.ID, "err", err)
83 + if errors.Is(err, user.ErrLastAdmin) {
84 + h.setFlashT(c, "flash.admin_last_admin")
85 + } else {
86 + h.setFlashT(c, "flash.admin_user_delete_failed")
87 + }
88 + return c.Redirect("/admin", fiber.StatusSeeOther)
89 + }
90 + h.setFlashT(c, "flash.admin_user_deleted", target.Username)
91 + return c.Redirect("/admin", fiber.StatusSeeOther)
92 +}
93 +
94 +func (h *Handler) AdminDeleteRepoSubmit(c *fiber.Ctx) error {
95 + id, err := strconv.ParseInt(c.Params("id"), 10, 64)
96 + if err != nil {
97 + return c.Redirect("/admin", fiber.StatusSeeOther)
98 + }
99 + r, err := h.repoSvc.GetByID(c.UserContext(), id)
100 + if err != nil {
101 + h.setFlashT(c, "flash.admin_repo_missing")
102 + return c.Redirect("/admin", fiber.StatusSeeOther)
103 + }
104 + if !strings.EqualFold(strings.TrimSpace(c.FormValue("confirm_name")), r.Name) {
105 + h.setFlashT(c, "flash.admin_delete_mismatch")
106 + return c.Redirect("/admin", fiber.StatusSeeOther)
107 + }
108 + if err := h.repoSvc.Delete(c.UserContext(), r); err != nil {
109 + h.logger.Error("admin delete repository failed", "repo_id", r.ID, "err", err)
110 + h.setFlashT(c, "flash.admin_repo_delete_failed")
111 + return c.Redirect("/admin", fiber.StatusSeeOther)
112 + }
113 + h.setFlashT(c, "flash.admin_repo_deleted", r.OwnerUsername, r.Name)
114 + return c.Redirect("/admin", fiber.StatusSeeOther)
115 +}
internal/web/handlers/auth.go
+75 -21
... ... @@ -1,12 +1,14 @@
1 1 package handlers
2 2
3 3 import (
4 - "encoding/base64"
4 + "errors"
5 5 "strconv"
6 6 "strings"
7 7
8 8 "polar/internal/auth"
9 + "polar/internal/db"
9 10 "polar/internal/i18n"
11 + "polar/internal/user"
10 12 "polar/internal/web/middleware"
11 13
12 14 "github.com/gofiber/fiber/v2"
... ... @@ -32,7 +34,11 @@ func (h *Handler) LoginSubmit(c *fiber.Ctx) error {
32 34 data := h.baseData(c, i18n.T(lang, "nav.login"))
33 35 data["FormLogin"] = login
34 36 data["Next"] = safeNextPath(c.FormValue("next"))
35 - data["Error"] = i18n.T(lang, "auth.invalid_creds")
37 + if errors.Is(err, user.ErrPendingApproval) {
38 + data["Error"] = i18n.T(lang, "auth.pending_approval")
39 + } else {
40 + data["Error"] = i18n.T(lang, "auth.invalid_creds")
41 + }
36 42 return h.render(c, fiber.StatusUnauthorized, "login", data)
37 43 }
38 44
... ... @@ -81,16 +87,25 @@ func (h *Handler) RegisterSubmit(c *fiber.Ctx) error {
81 87 email := strings.TrimSpace(c.FormValue("email"))
82 88 password := c.FormValue("password")
83 89
84 - regAllowed := h.isRegistrationAllowed(c)
85 - u, err := h.userSvc.Register(c.UserContext(), username, email, password, regAllowed)
90 + mode := h.db.GetRegistrationMode(c.UserContext(), h.cfg.Auth.RegistrationEnabled)
91 + u, err := h.userSvc.RegisterWithMode(c.UserContext(), username, email, password, mode)
86 92 if err != nil {
87 93 data := h.baseData(c, i18n.T(lang, "nav.register"))
88 94 data["FormUsername"] = username
89 95 data["FormEmail"] = email
90 - data["Error"] = err.Error()
96 + if errors.Is(err, user.ErrRegistrationDisabled) {
97 + data["Error"] = i18n.T(lang, "auth.register.disabled")
98 + } else {
99 + data["Error"] = err.Error()
100 + }
91 101 return h.render(c, fiber.StatusBadRequest, "register", data)
92 102 }
93 103
104 + if !u.IsApproved {
105 + h.setFlashT(c, "auth.pending_approval")
106 + return c.Redirect("/login", fiber.StatusSeeOther)
107 + }
108 +
94 109 sess, err := h.authSvc.CreateSession(c.UserContext(), u.ID)
95 110 if err != nil {
96 111 h.logger.Error("create session after register failed", "err", err)
... ... @@ -157,21 +172,36 @@ func (h *Handler) UpdateProfileSubmit(c *fiber.Ctx) error {
157 172 }
158 173
159 174 if c.FormValue("remove_avatar") == "true" {
160 - _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, "")
161 - } else if avatarData := strings.TrimSpace(c.FormValue("avatar_data")); avatarData != "" && strings.HasPrefix(avatarData, "data:image/") {
162 - _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, avatarData)
163 - } else if file, err := c.FormFile("avatar_file"); err == nil && file != nil && file.Size > 0 && file.Size <= 2*1024*1024 {
164 - if f, err := file.Open(); err == nil {
165 - defer f.Close()
166 - buf := make([]byte, file.Size)
167 - if _, err := f.Read(buf); err == nil {
168 - mime := file.Header.Get("Content-Type")
169 - if mime == "" {
170 - mime = "image/png"
171 - }
172 - dataURI := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(buf)
173 - _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, dataURI)
174 - }
175 + if err := h.userSvc.UpdateAvatar(c.UserContext(), u.ID, ""); err != nil {
176 + h.logger.Error("remove profile avatar failed", "user_id", u.ID, "err", err)
177 + h.setFlashT(c, "flash.image_save_failed")
178 + return c.Redirect("/settings", fiber.StatusSeeOther)
179 + }
180 + } else if avatarData, err := readImageField(c, "avatar_data", "avatar_file", maxAvatarImageBytes); err != nil {
181 + h.setFlashT(c, "flash.image_invalid")
182 + return c.Redirect("/settings", fiber.StatusSeeOther)
183 + } else if avatarData != "" {
184 + if err := h.userSvc.UpdateAvatar(c.UserContext(), u.ID, avatarData); err != nil {
185 + h.logger.Error("update profile avatar failed", "user_id", u.ID, "err", err)
186 + h.setFlashT(c, "flash.image_save_failed")
187 + return c.Redirect("/settings", fiber.StatusSeeOther)
188 + }
189 + }
190 +
191 + if c.FormValue("remove_background") == "true" {
192 + if err := h.userSvc.UpdateBackground(c.UserContext(), u.ID, ""); err != nil {
193 + h.logger.Error("remove profile background failed", "user_id", u.ID, "err", err)
194 + h.setFlashT(c, "flash.image_save_failed")
195 + return c.Redirect("/settings", fiber.StatusSeeOther)
196 + }
197 + } else if backgroundData, err := readImageField(c, "background_data", "background_file", maxBackgroundImageBytes); err != nil {
198 + h.setFlashT(c, "flash.image_invalid")
199 + return c.Redirect("/settings", fiber.StatusSeeOther)
200 + } else if backgroundData != "" {
201 + if err := h.userSvc.UpdateBackground(c.UserContext(), u.ID, backgroundData); err != nil {
202 + h.logger.Error("update profile background failed", "user_id", u.ID, "err", err)
203 + h.setFlashT(c, "flash.image_save_failed")
204 + return c.Redirect("/settings", fiber.StatusSeeOther)
175 205 }
176 206 }
177 207
... ... @@ -240,11 +270,29 @@ func (h *Handler) AdminPage(c *fiber.Ctx) error {
240 270 if err != nil {
241 271 h.logger.Error("list users failed", "err", err)
242 272 }
273 + repos, err := h.repoSvc.ListAccessible(c.UserContext(), middleware.CurrentUser(c), 200)
274 + if err != nil {
275 + h.logger.Error("list repositories failed", "err", err)
276 + }
243 277 data["Users"] = users
278 + data["Repos"] = repos
244 279 return h.render(c, fiber.StatusOK, "admin", data)
245 280 }
246 281
247 282 func (h *Handler) AdminSettingsSubmit(c *fiber.Ctx) error {
283 + if mode := strings.TrimSpace(c.FormValue("registration_mode")); mode != "" {
284 + if !db.ValidRegistrationMode(mode) {
285 + h.setFlashT(c, "flash.admin_registration_invalid")
286 + return c.Redirect("/admin", fiber.StatusSeeOther)
287 + }
288 + if err := h.db.SetRegistrationMode(c.UserContext(), mode); err != nil {
289 + h.logger.Error("update registration mode failed", "mode", mode, "err", err)
290 + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.settings_error", "err.settings_save")
291 + }
292 + h.setFlashT(c, "flash.settings_updated")
293 + return c.Redirect("/admin", fiber.StatusSeeOther)
294 + }
295 +
248 296 key := c.FormValue("setting_key")
249 297 val := c.FormValue("setting_val")
250 298
... ... @@ -261,7 +309,13 @@ func (h *Handler) AdminSettingsSubmit(c *fiber.Ctx) error {
261 309 }
262 310
263 311 enabled := val == "true" || val == "1" || val == "on"
264 - if err := h.db.SetBoolSetting(c.UserContext(), key, enabled); err != nil {
312 + var err error
313 + if key == "registration_enabled" {
314 + err = h.db.SetRegistrationEnabled(c.UserContext(), enabled)
315 + } else {
316 + err = h.db.SetBoolSetting(c.UserContext(), key, enabled)
317 + }
318 + if err != nil {
265 319 h.logger.Error("update admin setting failed", "key", key, "err", err)
266 320 return h.renderErrorT(c, fiber.StatusInternalServerError, "err.settings_error", "err.settings_save")
267 321 }
internal/web/handlers/handlers.go
+11 -2
... ... @@ -93,6 +93,7 @@ func (h *Handler) RegisterRoutes(app *fiber.App) {
93 93 app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit)
94 94 app.Get("/settings", middleware.RequireAuth(), h.SettingsPage)
95 95 app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit)
96 + app.Post("/settings/delete", middleware.RequireAuth(), h.DeleteAccountSubmit)
96 97 app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit)
97 98 app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit)
98 99 app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit)
... ... @@ -100,6 +101,10 @@ func (h *Handler) RegisterRoutes(app *fiber.App) {
100 101 // Admin routes
101 102 app.Get("/admin", middleware.RequireAdmin(), h.AdminPage)
102 103 app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit)
104 + app.Post("/admin/users/:id/approve", middleware.RequireAdmin(), h.AdminApproveUserSubmit)
105 + app.Post("/admin/users/:id/admin", middleware.RequireAdmin(), h.AdminToggleUserAdminSubmit)
106 + app.Post("/admin/users/:id/delete", middleware.RequireAdmin(), h.AdminDeleteUserSubmit)
107 + app.Post("/admin/repos/:id/delete", middleware.RequireAdmin(), h.AdminDeleteRepoSubmit)
103 108
104 109 // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*)
105 110 app.Get("/:owner/:repo/info/refs", h.GitInfoRefs)
... ... @@ -209,6 +214,8 @@ func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any {
209 214 paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25)
210 215 }
211 216
217 + registrationMode := h.db.GetRegistrationMode(ctx, h.cfg.Auth.RegistrationEnabled)
218 + registrationOpen := h.isRegistrationAllowed(c)
212 219 data := map[string]any{
213 220 "Title": title,
214 221 "Lang": lang,
... ... @@ -216,7 +223,9 @@ func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any {
216 223 "CSRFToken": csrf,
217 224 "CurrentUser": u,
218 225 "Flash": flash,
219 - "RegistrationEnabled": h.isRegistrationAllowed(c),
226 + "RegistrationMode": registrationMode,
227 + "RegistrationOpen": registrationOpen,
228 + "RegistrationEnabled": registrationOpen,
220 229 "ExploreEnabled": h.db.IsExploreEnabled(ctx),
221 230 "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled),
222 231 "AnonymousReadEnabled": anonRead,
... ... @@ -293,7 +302,7 @@ func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool {
293 302 if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 {
294 303 return true
295 304 }
296 - return h.db.IsRegistrationEnabled(c.UserContext(), h.cfg.Auth.RegistrationEnabled)
305 + return h.db.GetRegistrationMode(c.UserContext(), h.cfg.Auth.RegistrationEnabled) != db.RegistrationModeClosed
297 306 }
298 307
299 308 func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error {
internal/web/handlers/media.go
+81 -0
... ... @@ -0,0 +1,81 @@
1 +package handlers
2 +
3 +import (
4 + "encoding/base64"
5 + "errors"
6 + "io"
7 + "net/http"
8 + "strings"
9 +
10 + "github.com/gofiber/fiber/v2"
11 +)
12 +
13 +const (
14 + maxAvatarImageBytes = 2 << 20
15 + maxBackgroundImageBytes = 6 << 20
16 +)
17 +
18 +var (
19 + errImageTooLarge = errors.New("image is too large")
20 + errImageType = errors.New("unsupported image type")
21 +)
22 +
23 +func validImageDataURI(value string) bool {
24 + for _, prefix := range []string{
25 + "data:image/png;base64,",
26 + "data:image/jpeg;base64,",
27 + "data:image/webp;base64,",
28 + "data:image/gif;base64,",
29 + } {
30 + if strings.HasPrefix(value, prefix) {
31 + return true
32 + }
33 + }
34 + return false
35 +}
36 +
37 +// readImageField accepts either a browser-resized data URI or a multipart
38 +// upload for one image field. An empty result means the field was not changed.
39 +func readImageField(c *fiber.Ctx, dataField, fileField string, maxBytes int64) (string, error) {
40 + if data := strings.TrimSpace(c.FormValue(dataField)); data != "" {
41 + if !validImageDataURI(data) {
42 + return "", errImageType
43 + }
44 + if int64(len(data)) > maxBytes*4/3+4096 {
45 + return "", errImageTooLarge
46 + }
47 + return data, nil
48 + }
49 +
50 + file, err := c.FormFile(fileField)
51 + if err != nil || file == nil || file.Size <= 0 {
52 + return "", nil
53 + }
54 + if file.Size > maxBytes {
55 + return "", errImageTooLarge
56 + }
57 +
58 + f, err := file.Open()
59 + if err != nil {
60 + return "", err
61 + }
62 + defer f.Close()
63 +
64 + buf, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
65 + if err != nil {
66 + return "", err
67 + }
68 + if int64(len(buf)) > maxBytes {
69 + return "", errImageTooLarge
70 + }
71 + mime := http.DetectContentType(buf)
72 + if semi := strings.IndexByte(mime, ';'); semi >= 0 {
73 + mime = mime[:semi]
74 + }
75 + switch mime {
76 + case "image/png", "image/jpeg", "image/gif", "image/webp":
77 + default:
78 + return "", errImageType
79 + }
80 + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(buf), nil
81 +}
internal/web/handlers/repo.go
+17 -0
... ... @@ -347,6 +347,23 @@ func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error {
347 347 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
348 348 }
349 349
350 + if c.FormValue("remove_avatar") == "true" {
351 + if err := h.repoSvc.UpdateAvatar(c.UserContext(), r.ID, ""); err != nil {
352 + h.logger.Error("remove repository avatar failed", "repo_id", r.ID, "err", err)
353 + h.setFlashT(c, "flash.image_save_failed")
354 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
355 + }
356 + } else if avatarData, err := readImageField(c, "avatar_data", "avatar_file", maxAvatarImageBytes); err != nil {
357 + h.setFlashT(c, "flash.image_invalid")
358 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
359 + } else if avatarData != "" {
360 + if err := h.repoSvc.UpdateAvatar(c.UserContext(), r.ID, avatarData); err != nil {
361 + h.logger.Error("update repository avatar failed", "repo_id", r.ID, "err", err)
362 + h.setFlashT(c, "flash.image_save_failed")
363 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
364 + }
365 + }
366 +
350 367 h.setFlashT(c, "flash.repo_settings_saved")
351 368 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
352 369 }
internal/web/middleware/middleware.go
+1 -1
... ... @@ -90,7 +90,7 @@ func SessionAuth(authSvc *auth.Service, userSvc *user.Service) fiber.Handler {
90 90 sessID := c.Cookies(SessionCookieName)
91 91 if sessID != "" {
92 92 if sess, err := authSvc.GetSession(c.UserContext(), sessID); err == nil && sess != nil {
93 - if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil {
93 + if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil && u.IsApproved {
94 94 c.Locals("user", u)
95 95 c.Locals("session", sess)
96 96 c.Locals("csrf_token", sess.CSRFToken)
internal/web/static/css/app.css
+178 -1
... ... @@ -1477,4 +1477,181 @@ kbd, .kbd {
1477 1477 .gap-1\.5 { gap: 0.375rem; }
1478 1478 .gap-5 { gap: 1.25rem; }
1479 1479 .mb-1\.5 { margin-bottom: 0.375rem; }
1480 -.pt-1 { padding-top: 0.25rem; }
1480 +.pt-1 { padding-top: 0.25rem; }
1481 +
1482 +/* Profile vignette cover */
1483 +.profile-hero {
1484 + position: relative;
1485 + overflow: hidden;
1486 + isolation: isolate;
1487 + padding: 1.4rem 1.45rem;
1488 + border: 1px solid hsl(var(--border));
1489 + border-radius: calc(var(--radius) * 1.7);
1490 + background: hsl(var(--card));
1491 +}
1492 +.profile-hero.has-bg {
1493 + border-color: hsl(var(--border) / 0.7);
1494 + min-height: 12.5rem;
1495 +}
1496 +.profile-hero-bg {
1497 + position: absolute;
1498 + inset: 0;
1499 + z-index: -2;
1500 + pointer-events: none;
1501 +}
1502 +.profile-hero-bg img {
1503 + width: 100%;
1504 + height: 100%;
1505 + object-fit: cover;
1506 + object-position: center 32%;
1507 + opacity: 0.92;
1508 + -webkit-mask-image: radial-gradient(120% 150% at 50% 22%, #000 34%, rgba(0,0,0,0.9) 55%, transparent 100%);
1509 + mask-image: radial-gradient(120% 150% at 50% 22%, #000 34%, rgba(0,0,0,0.9) 55%, transparent 100%);
1510 +}
1511 +.profile-hero.has-bg::after {
1512 + content: "";
1513 + position: absolute;
1514 + inset: 0;
1515 + z-index: -1;
1516 + pointer-events: none;
1517 + background:
1518 + linear-gradient(to top, hsl(var(--background)) 2%, hsl(var(--background) / 0.84) 44%, hsl(var(--background) / 0.18) 100%),
1519 + radial-gradient(90% 90% at 12% 30%, hsl(var(--background) / 0.38), transparent 62%);
1520 +}
1521 +.profile-hero-content {
1522 + position: relative;
1523 + z-index: 1;
1524 +}
1525 +.profile-hero.has-bg .avatar-xxl {
1526 + box-shadow: 0 0 0 4px hsl(var(--background) / 0.88), 0 0 0 5px hsl(var(--border));
1527 + backdrop-filter: blur(2px);
1528 +}
1529 +
1530 +/* Media settings */
1531 +.background-preview {
1532 + position: relative;
1533 + width: 100%;
1534 + aspect-ratio: 16 / 6;
1535 + min-height: 7rem;
1536 + overflow: hidden;
1537 + border: 1px solid hsl(var(--border));
1538 + border-radius: calc(var(--radius) * 1.3);
1539 + background:
1540 + radial-gradient(100% 130% at 50% 30%, hsl(var(--muted)) 0%, hsl(var(--card)) 72%);
1541 +}
1542 +.background-preview img {
1543 + width: 100%;
1544 + height: 100%;
1545 + object-fit: cover;
1546 + object-position: center;
1547 + display: block;
1548 + -webkit-mask-image: radial-gradient(120% 145% at 50% 25%, #000 38%, rgba(0,0,0,0.86) 58%, transparent 100%);
1549 + mask-image: radial-gradient(120% 145% at 50% 25%, #000 38%, rgba(0,0,0,0.86) 58%, transparent 100%);
1550 +}
1551 +.background-preview::after {
1552 + content: "";
1553 + position: absolute;
1554 + inset: 0;
1555 + background: linear-gradient(to top, hsl(var(--background) / 0.75), hsl(var(--background) / 0.05));
1556 + pointer-events: none;
1557 +}
1558 +.background-preview.is-empty {
1559 + display: flex;
1560 + align-items: center;
1561 + justify-content: center;
1562 +}
1563 +.background-preview-placeholder {
1564 + color: hsl(var(--muted-foreground) / 0.7);
1565 +}
1566 +
1567 +/* Registration policy selector */
1568 +.registration-options {
1569 + display: grid;
1570 + grid-template-columns: repeat(3, minmax(0, 1fr));
1571 + gap: 0.55rem;
1572 +}
1573 +.registration-option {
1574 + display: flex;
1575 + align-items: flex-start;
1576 + gap: 0.6rem;
1577 + padding: 0.75rem;
1578 + border: 1px solid hsl(var(--border));
1579 + border-radius: calc(var(--radius) * 1.2);
1580 + background: hsl(var(--muted) / 0.18);
1581 + cursor: pointer;
1582 + transition: border-color 0.15s ease, background-color 0.15s ease;
1583 +}
1584 +.registration-option:hover {
1585 + border-color: hsl(var(--foreground) / 0.28);
1586 + background: hsl(var(--muted) / 0.32);
1587 +}
1588 +.registration-option:has(input:checked) {
1589 + border-color: hsl(var(--foreground) / 0.72);
1590 + background: hsl(var(--secondary));
1591 +}
1592 +.registration-option input {
1593 + margin-top: 0.14rem;
1594 + flex-shrink: 0;
1595 +}
1596 +.registration-option strong,
1597 +.registration-option small {
1598 + display: block;
1599 +}
1600 +.registration-option strong {
1601 + font-size: 0.8rem;
1602 + line-height: 1.3;
1603 +}
1604 +.registration-option small {
1605 + margin-top: 0.18rem;
1606 + color: hsl(var(--muted-foreground));
1607 + font-size: 0.7rem;
1608 + line-height: 1.4;
1609 +}
1610 +
1611 +/* Admin actions */
1612 +.admin-row-actions {
1613 + display: flex;
1614 + align-items: center;
1615 + justify-content: flex-end;
1616 + gap: 0.25rem;
1617 + flex-wrap: wrap;
1618 +}
1619 +.admin-row-actions form {
1620 + margin: 0;
1621 + display: inline-flex;
1622 +}
1623 +.admin-repo-row {
1624 + gap: 0.75rem;
1625 +}
1626 +.admin-repo-link {
1627 + display: flex;
1628 + align-items: center;
1629 + gap: 0.6rem;
1630 + min-width: 0;
1631 + flex: 1;
1632 + color: inherit;
1633 + text-decoration: none;
1634 +}
1635 +.admin-repo-link:hover strong {
1636 + text-decoration: underline;
1637 + text-underline-offset: 2px;
1638 +}
1639 +
1640 +.segmented-btn {
1641 + text-decoration: none;
1642 +}
1643 +
1644 +@media (max-width: 760px) {
1645 + .registration-options {
1646 + grid-template-columns: 1fr;
1647 + }
1648 + .profile-hero {
1649 + padding: 1.05rem;
1650 + }
1651 + .profile-hero.has-bg {
1652 + min-height: 10.8rem;
1653 + }
1654 + .background-preview {
1655 + aspect-ratio: 16 / 7;
1656 + }
1657 +}
internal/web/static/js/app.js
+156 -0
... ... @@ -464,6 +464,162 @@
464 464 }
465 465 });
466 466
467 + function resizeCoverImage(file, width, height, callback) {
468 + var reader = new FileReader();
469 + reader.onload = function (ev) {
470 + var img = new Image();
471 + img.onload = function () {
472 + var canvas = document.createElement('canvas');
473 + canvas.width = width;
474 + canvas.height = height;
475 + var ctx = canvas.getContext('2d');
476 + var scale = Math.max(width / img.width, height / img.height);
477 + var sw = width / scale;
478 + var sh = height / scale;
479 + var sx = (img.width - sw) / 2;
480 + var sy = (img.height - sh) / 2;
481 + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
482 + var dataUrl = canvas.toDataURL('image/webp', 0.84);
483 + if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
484 + dataUrl = canvas.toDataURL('image/jpeg', 0.84);
485 + }
486 + callback(dataUrl);
487 + };
488 + img.src = ev.target.result;
489 + };
490 + reader.readAsDataURL(file);
491 + }
492 +
493 + function resizeSquareImage(file, size, callback) {
494 + var reader = new FileReader();
495 + reader.onload = function (ev) {
496 + var img = new Image();
497 + img.onload = function () {
498 + var canvas = document.createElement('canvas');
499 + canvas.width = size;
500 + canvas.height = size;
501 + var ctx = canvas.getContext('2d');
502 + var minSide = Math.min(img.width, img.height);
503 + var sx = (img.width - minSide) / 2;
504 + var sy = (img.height - minSide) / 2;
505 + ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size);
506 + var dataUrl = canvas.toDataURL('image/webp', 0.88);
507 + if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
508 + dataUrl = canvas.toDataURL('image/jpeg', 0.88);
509 + }
510 + callback(dataUrl);
511 + };
512 + img.src = ev.target.result;
513 + };
514 + reader.readAsDataURL(file);
515 + }
516 +
517 + document.addEventListener('change', function (e) {
518 + if (!e.target) return;
519 + if (e.target.id === 'background-file-input') {
520 + var bgFile = e.target.files && e.target.files[0];
521 + if (!bgFile) return;
522 + resizeCoverImage(bgFile, 1600, 700, function (dataUrl) {
523 + var hidden = document.getElementById('background-data-input');
524 + var remove = document.getElementById('remove-background-input');
525 + var preview = document.getElementById('background-preview');
526 + if (hidden) hidden.value = dataUrl;
527 + if (remove) remove.value = 'false';
528 + if (preview) {
529 + preview.classList.remove('is-empty');
530 + preview.innerHTML = '<img src="' + dataUrl + '" alt="">';
531 + }
532 + });
533 + return;
534 + }
535 + if (e.target.id === 'repo-avatar-file-input') {
536 + var repoFile = e.target.files && e.target.files[0];
537 + if (!repoFile) return;
538 + resizeSquareImage(repoFile, 256, function (dataUrl) {
539 + var hidden = document.getElementById('repo-avatar-data-input');
540 + var remove = document.getElementById('remove-repo-avatar-input');
541 + var preview = document.getElementById('repo-avatar-preview-wrap');
542 + if (hidden) hidden.value = dataUrl;
543 + if (remove) remove.value = 'false';
544 + if (preview) {
545 + preview.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Repository avatar" class="avatar-img"></span>';
546 + }
547 + });
548 + }
549 + });
550 +
551 + document.addEventListener('click', function (e) {
552 + var removeBg = e.target.closest('#remove-background-btn');
553 + if (removeBg) {
554 + e.preventDefault();
555 + var hidden = document.getElementById('background-data-input');
556 + var remove = document.getElementById('remove-background-input');
557 + var fileInput = document.getElementById('background-file-input');
558 + var preview = document.getElementById('background-preview');
559 + if (hidden) hidden.value = '';
560 + if (remove) remove.value = 'true';
561 + if (fileInput) fileInput.value = '';
562 + if (preview) {
563 + preview.classList.add('is-empty');
564 + preview.innerHTML = '<span class="background-preview-placeholder"><svg class="icon size-5" aria-hidden="true"><use href="#hi-image"></use></svg></span>';
565 + }
566 + removeBg.style.display = 'none';
567 + return;
568 + }
569 +
570 + var removeRepoAvatar = e.target.closest('#remove-repo-avatar-btn');
571 + if (removeRepoAvatar) {
572 + e.preventDefault();
573 + var hidden = document.getElementById('repo-avatar-data-input');
574 + var remove = document.getElementById('remove-repo-avatar-input');
575 + var fileInput = document.getElementById('repo-avatar-file-input');
576 + var preview = document.getElementById('repo-avatar-preview-wrap');
577 + if (hidden) hidden.value = '';
578 + if (remove) remove.value = 'true';
579 + if (fileInput) fileInput.value = '';
580 + if (preview) preview.innerHTML = '<span class="avatar avatar-xl">?</span>';
581 + removeRepoAvatar.style.display = 'none';
582 + return;
583 + }
584 +
585 + var deleteUser = e.target.closest('[data-admin-delete-user]');
586 + if (deleteUser) {
587 + e.preventDefault();
588 + var name = deleteUser.getAttribute('data-admin-delete-name') || '';
589 + var userForm = document.getElementById('admin-delete-user-form');
590 + var userInput = document.getElementById('admin_delete_confirm_user');
591 + var userTarget = document.getElementById('admin-delete-user-target');
592 + var userSubmit = document.getElementById('admin-delete-user-submit');
593 + if (userForm) userForm.setAttribute('action', '/admin/users/' + deleteUser.getAttribute('data-admin-delete-user') + '/delete');
594 + if (userTarget) userTarget.textContent = '@' + name;
595 + if (userInput) {
596 + userInput.value = '';
597 + userInput.setAttribute('data-confirm-input', name);
598 + }
599 + if (userSubmit) userSubmit.disabled = true;
600 + openDialog('admin-delete-user-dialog');
601 + return;
602 + }
603 +
604 + var deleteRepo = e.target.closest('[data-admin-delete-repo]');
605 + if (deleteRepo) {
606 + e.preventDefault();
607 + var repoName = deleteRepo.getAttribute('data-admin-delete-name') || '';
608 + var repoForm = document.getElementById('admin-delete-repo-form');
609 + var repoInput = document.getElementById('admin_delete_confirm_repo');
610 + var repoTarget = document.getElementById('admin-delete-repo-target');
611 + var repoSubmit = document.getElementById('admin-delete-repo-submit');
612 + if (repoForm) repoForm.setAttribute('action', '/admin/repos/' + deleteRepo.getAttribute('data-admin-delete-repo') + '/delete');
613 + if (repoTarget) repoTarget.textContent = repoName;
614 + if (repoInput) {
615 + repoInput.value = '';
616 + repoInput.setAttribute('data-confirm-input', repoName);
617 + }
618 + if (repoSubmit) repoSubmit.disabled = true;
619 + openDialog('admin-delete-repo-dialog');
620 + }
621 + });
622 +
467 623 // Repo header: the description stays on the title line and expands onto its
468 624 // own line when clicked. The avatar is sized to the title line so it always
469 625 // sits on the same row as the repository name, at a modest size.
internal/web/views/layouts/base.html
+15 -4
... ... @@ -148,6 +148,11 @@
148 148 <button type="button" class="btn btn-ghost btn-icon" data-open-palette="" aria-label="{{t .Lang "nav.command_palette"}}">
149 149 {{icon "search" "size-4"}}
150 150 </button>
151 + {{if eq .Lang "en"}}
152 + <a href="?lang=ru" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}" aria-label="{{t .Lang "nav.lang"}}">RU</a>
153 + {{else}}
154 + <a href="?lang=en" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}" aria-label="{{t .Lang "nav.lang"}}">EN</a>
155 + {{end}}
151 156 {{if .CurrentUser}}
152 157 <a href="/new" class="btn btn-primary btn-sm">{{icon "plus" "size-3.5"}}</a>
153 158 {{else}}
... ... @@ -201,10 +206,16 @@
201 206 {{end}}
202 207 </nav>
203 208 </div>
204 - <div class="segmented">
205 - <button type="button" class="segmented-btn" data-set-theme="light">{{t .Lang "theme.light"}}</button>
206 - <button type="button" class="segmented-btn" data-set-theme="dark">{{t .Lang "theme.dark"}}</button>
207 - <button type="button" class="segmented-btn" data-set-theme="system">{{t .Lang "theme.system"}}</button>
209 + <div class="flex flex-col gap-2">
210 + <div class="segmented" role="group" aria-label="{{t .Lang "nav.lang"}}">
211 + <a href="?lang=ru" class="segmented-btn {{if eq .Lang "ru"}}active{{end}}">RU</a>
212 + <a href="?lang=en" class="segmented-btn {{if eq .Lang "en"}}active{{end}}">EN</a>
213 + </div>
214 + <div class="segmented" role="group" aria-label="{{t .Lang "nav.theme"}}">
215 + <button type="button" class="segmented-btn {{if eq .Theme "light"}}active{{end}}" data-set-theme="light">{{t .Lang "theme.light"}}</button>
216 + <button type="button" class="segmented-btn {{if eq .Theme "dark"}}active{{end}}" data-set-theme="dark">{{t .Lang "theme.dark"}}</button>
217 + <button type="button" class="segmented-btn {{if eq .Theme "system"}}active{{end}}" data-set-theme="system">{{t .Lang "theme.system"}}</button>
218 + </div>
208 219 </div>
209 220 </div>
210 221 </div>
internal/web/views/pages/admin.html
+157 -39
... ... @@ -4,13 +4,49 @@
4 4 <h1 class="text-lg font-semibold" style="margin:0;">{{t .Lang "admin.title"}}</h1>
5 5 </div>
6 6
7 + <!-- Registration policy -->
8 + <section class="card mb-4">
9 + <div class="card-header">
10 + <span class="font-semibold flex items-center gap-2">{{icon "user" "size-4"}} {{t .Lang "admin.registration"}}</span>
11 + </div>
12 + <form method="post" action="/admin/settings" class="card-body flex flex-col gap-3">
13 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
14 + <div class="text-xs text-muted">{{t .Lang "admin.registration.mode"}}</div>
15 + <div class="registration-options">
16 + <label class="registration-option">
17 + <input type="radio" name="registration_mode" value="public" {{if eq .RegistrationMode "public"}}checked{{end}}>
18 + <span>
19 + <strong>{{t .Lang "admin.registration.public"}}</strong>
20 + <small>{{t .Lang "admin.registration.public_desc"}}</small>
21 + </span>
22 + </label>
23 + <label class="registration-option">
24 + <input type="radio" name="registration_mode" value="confirm" {{if eq .RegistrationMode "confirm"}}checked{{end}}>
25 + <span>
26 + <strong>{{t .Lang "admin.registration.confirm"}}</strong>
27 + <small>{{t .Lang "admin.registration.confirm_desc"}}</small>
28 + </span>
29 + </label>
30 + <label class="registration-option">
31 + <input type="radio" name="registration_mode" value="closed" {{if eq .RegistrationMode "closed"}}checked{{end}}>
32 + <span>
33 + <strong>{{t .Lang "admin.registration.closed"}}</strong>
34 + <small>{{t .Lang "admin.registration.closed_desc"}}</small>
35 + </span>
36 + </label>
37 + </div>
38 + <div class="flex justify-end">
39 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "common.save"}}</button>
40 + </div>
41 + </form>
42 + </section>
43 +
7 44 <!-- Feature Toggles (Modules & Access) -->
8 45 <section class="card mb-4">
9 46 <div class="card-header">
10 47 <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "admin.features"}}</span>
11 48 </div>
12 49 <div class="dense-list">
13 - <!-- 1. Explore Page -->
14 50 <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
15 51 <div class="flex flex-col gap-1 min-w-0">
16 52 <div class="flex items-center gap-2 font-medium text-sm">
... ... @@ -37,35 +73,6 @@
37 73 </form>
38 74 </div>
39 75
40 - <!-- 2. Public Registration -->
41 - <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
42 - <div class="flex flex-col gap-1 min-w-0">
43 - <div class="flex items-center gap-2 font-medium text-sm">
44 - {{icon "user" "size-4"}}
45 - <span>{{t .Lang "admin.registration"}}</span>
46 - {{if .RegistrationEnabled}}
47 - <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span>
48 - {{else}}
49 - <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span>
50 - {{end}}
51 - </div>
52 - <div class="text-xs text-muted">{{t .Lang "admin.registration.desc"}}</div>
53 - </div>
54 - <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;">
55 - <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
56 - <input type="hidden" name="setting_key" value="registration_enabled">
57 - {{if .RegistrationEnabled}}
58 - <input type="hidden" name="setting_val" value="false">
59 - <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button>
60 - {{else}}
61 - <input type="hidden" name="setting_val" value="true">
62 - <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button>
63 - {{end}}
64 - </form>
65 - </div>
66 -
67 -
68 - <!-- 4. Landing Page -->
69 76 <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
70 77 <div class="flex flex-col gap-1 min-w-0">
71 78 <div class="flex items-center gap-2 font-medium text-sm">
... ... @@ -92,7 +99,6 @@
92 99 </form>
93 100 </div>
94 101
95 - <!-- 5. Anonymous Read Access -->
96 102 <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
97 103 <div class="flex flex-col gap-1 min-w-0">
98 104 <div class="flex items-center gap-2 font-medium text-sm">
... ... @@ -121,8 +127,8 @@
121 127 </div>
122 128 </section>
123 129
124 - <!-- Users Table -->
125 - <section class="card">
130 + <!-- Users -->
131 + <section class="card mb-4">
126 132 <div class="card-header">
127 133 <span class="font-semibold flex items-center gap-2">{{icon "users" "size-4"}} {{t .Lang "admin.users"}} ({{len .Users}})</span>
128 134 </div>
... ... @@ -130,28 +136,33 @@
130 136 <table class="table">
131 137 <thead>
132 138 <tr>
133 - <th>ID</th>
134 139 <th>{{t .Lang "admin.col.user"}}</th>
135 - <th>{{t .Lang "auth.email"}}</th>
140 + <th>{{t .Lang "admin.col.status"}}</th>
136 141 <th>{{t .Lang "admin.col.repos"}}</th>
137 142 <th>{{t .Lang "admin.col.role"}}</th>
138 143 <th>{{t .Lang "admin.col.joined"}}</th>
144 + <th style="text-align:right;">{{t .Lang "admin.col.actions"}}</th>
139 145 </tr>
140 146 </thead>
141 147 <tbody>
142 148 {{range .Users}}
143 149 <tr>
144 - <td class="font-mono text-xs text-muted">#{{.ID}}</td>
145 150 <td>
146 151 <a href="/{{.Username}}" class="flex items-center gap-2 font-medium">
147 152 {{avatar .Username .AvatarURL}}
148 - <div class="flex flex-col">
153 + <span class="flex flex-col">
149 154 <span>{{.Username}}</span>
150 155 {{if .FullName}}<span class="text-xs text-muted">{{.FullName}}</span>{{end}}
151 - </div>
156 + </span>
152 157 </a>
153 158 </td>
154 - <td class="text-xs text-muted">{{.Email}}</td>
159 + <td>
160 + {{if .IsApproved}}
161 + <span class="badge badge-outline">{{t $.Lang "admin.approved"}}</span>
162 + {{else}}
163 + <span class="badge badge-primary">{{t $.Lang "admin.pending"}}</span>
164 + {{end}}
165 + </td>
155 166 <td class="font-mono text-xs">{{.RepoCount}}</td>
156 167 <td>
157 168 {{if .IsAdmin}}
... ... @@ -161,11 +172,118 @@
161 172 {{end}}
162 173 </td>
163 174 <td class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</td>
175 + <td>
176 + <div class="admin-row-actions">
177 + {{if not .IsApproved}}
178 + <form method="post" action="/admin/users/{{.ID}}/approve">
179 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
180 + <button type="submit" class="btn btn-primary btn-sm" title="{{t $.Lang "admin.approve"}}">
181 + {{icon "check" "size-3.5"}}<span class="hidden md-flex">{{t $.Lang "admin.approve"}}</span>
182 + </button>
183 + </form>
184 + {{end}}
185 + <form method="post" action="/admin/users/{{.ID}}/admin">
186 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
187 + <button type="submit" class="btn btn-ghost btn-sm" title="{{if .IsAdmin}}{{t $.Lang "admin.remove_admin"}}{{else}}{{t $.Lang "admin.make_admin"}}{{end}}">
188 + {{icon "shield" "size-3.5"}}
189 + </button>
190 + </form>
191 + {{if ne .ID $.CurrentUser.ID}}
192 + <button type="button" class="btn btn-ghost btn-sm text-destructive" title="{{t $.Lang "admin.delete_user"}}"
193 + data-admin-delete-user="{{.ID}}" data-admin-delete-name="{{.Username}}">
194 + {{icon "trash" "size-3.5"}}
195 + </button>
196 + {{end}}
197 + </div>
198 + </td>
164 199 </tr>
165 200 {{end}}
166 201 </tbody>
167 202 </table>
168 203 </div>
169 204 </section>
205 +
206 + <!-- Repositories -->
207 + <section class="card">
208 + <div class="card-header">
209 + <span class="font-semibold flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "admin.repos"}} ({{len .Repos}})</span>
210 + </div>
211 + {{if .Repos}}
212 + <div class="dense-list">
213 + {{range .Repos}}
214 + <div class="dense-row admin-repo-row">
215 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="admin-repo-link">
216 + {{avatar .OwnerUsername (or .AvatarURL .OwnerAvatarURL)}}
217 + <span class="flex flex-col min-w-0">
218 + <span class="text-sm"><span class="text-muted">{{.OwnerUsername}}/</span><strong>{{.Name}}</strong></span>
219 + {{if .Description}}<span class="text-xs text-muted truncate">{{.Description}}</span>{{end}}
220 + </span>
221 + </a>
222 + <div class="admin-row-actions">
223 + <span class="badge badge-outline">{{if .IsPrivate}}{{t $.Lang "repo.private"}}{{else}}{{t $.Lang "repo.public"}}{{end}}</span>
224 + <button type="button" class="btn btn-ghost btn-sm text-destructive" title="{{t $.Lang "admin.delete_repo"}}"
225 + data-admin-delete-repo="{{.ID}}" data-admin-delete-name="{{.Name}}">
226 + {{icon "trash" "size-3.5"}}
227 + </button>
228 + </div>
229 + </div>
230 + {{end}}
231 + </div>
232 + {{else}}
233 + <div class="card-body text-xs text-muted">{{t .Lang "repo.empty_list"}}</div>
234 + {{end}}
235 + </section>
236 +</div>
237 +
238 +<!-- Delete user confirmation -->
239 +<div id="admin-delete-user-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "admin.delete_user_title"}}" aria-hidden="true">
240 + <div class="dialog-box">
241 + <div class="card-header">
242 + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "admin.delete_user_title"}}</span>
243 + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button>
244 + </div>
245 + <form method="post" action="/admin/users/0/delete" id="admin-delete-user-form">
246 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
247 + <div class="card-body flex flex-col gap-3">
248 + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "admin.delete_user_desc"}}</p>
249 + <div class="text-sm">{{t .Lang "admin.col.user"}}: <strong class="font-mono" id="admin-delete-user-target"></strong></div>
250 + <div>
251 + <label class="label" for="admin_delete_confirm_user">{{t .Lang "admin.confirm_user"}}</label>
252 + <input id="admin_delete_confirm_user" type="text" name="confirm_username" class="input font-mono" autocomplete="off" required
253 + data-confirm-input="" data-confirm-button="admin-delete-user-submit">
254 + </div>
255 + </div>
256 + <div class="card-footer flex items-center justify-end gap-2">
257 + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button>
258 + <button id="admin-delete-user-submit" type="submit" class="btn btn-destructive btn-sm" disabled>{{t .Lang "admin.delete_user"}}</button>
259 + </div>
260 + </form>
261 + </div>
262 +</div>
263 +
264 +<!-- Delete repository confirmation -->
265 +<div id="admin-delete-repo-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "admin.delete_repo_title"}}" aria-hidden="true">
266 + <div class="dialog-box">
267 + <div class="card-header">
268 + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "admin.delete_repo_title"}}</span>
269 + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button>
270 + </div>
271 + <form method="post" action="/admin/repos/0/delete" id="admin-delete-repo-form">
272 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
273 + <div class="card-body flex flex-col gap-3">
274 + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "admin.delete_repo_desc"}}</p>
275 + <div class="text-sm">{{t .Lang "admin.repos"}}: <strong class="font-mono" id="admin-delete-repo-target"></strong></div>
276 + <div>
277 + <label class="label" for="admin_delete_confirm_repo">{{t .Lang "admin.confirm_repo"}}</label>
278 + <input id="admin_delete_confirm_repo" type="text" name="confirm_name" class="input font-mono" autocomplete="off" required
279 + data-confirm-input="" data-confirm-button="admin-delete-repo-submit">
280 + </div>
281 + </div>
282 + <div class="card-footer flex items-center justify-end gap-2">
283 + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button>
284 + <button id="admin-delete-repo-submit" type="submit" class="btn btn-destructive btn-sm" disabled>{{t .Lang "admin.delete_repo"}}</button>
285 + </div>
286 + </form>
287 + </div>
170 288 </div>
171 289 {{end}}
internal/web/views/pages/login.html
+1 -1
... ... @@ -19,7 +19,7 @@
19 19 <span>{{t .Lang "auth.submit_login"}}</span>
20 20 </button>
21 21 </form>
22 - {{if .RegistrationEnabled}}
22 + {{if .RegistrationOpen}}
23 23 <div class="card-footer text-xs text-muted flex items-center justify-between">
24 24 <span>{{t .Lang "auth.no_account"}}</span>
25 25 <a href="/register" class="font-medium" style="color:hsl(var(--foreground));">{{t .Lang "nav.register"}} →</a>
internal/web/views/pages/profile.html
+4 -2
... ... @@ -1,8 +1,10 @@
1 1 {{define "content"}}
2 2 <div class="container">
3 3 <!-- Profile -->
4 - <section class="mb-4" style="padding-bottom:1.4rem;border-bottom:1px solid hsl(var(--border));">
5 - <div class="flex items-start flex-wrap">
4 + {{$bg := imageURL .ProfileUser.BackgroundURL}}
5 + <section class="profile-hero mb-4{{if $bg}} has-bg{{end}}">
6 + {{if $bg}}<div class="profile-hero-bg" aria-hidden="true"><img src="{{$bg}}" alt=""></div>{{end}}
7 + <div class="profile-hero-content flex items-start flex-wrap">
6 8 {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xxl"}}
7 9 <div class="flex-1 min-w-0" style="margin-left:1.6rem;min-width:15rem;">
8 10 <div class="flex items-center gap-2 flex-wrap">
internal/web/views/pages/register.html
+6 -1
... ... @@ -4,7 +4,7 @@
4 4 <div class="card-header">
5 5 <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "auth.register.title"}}</h1>
6 6 </div>
7 - {{if not .RegistrationEnabled}}
7 + {{if not .RegistrationOpen}}
8 8 <div class="card-body text-sm text-muted">
9 9 <div class="flex items-center gap-2 text-destructive">
10 10 {{icon "lock" "size-4"}}
... ... @@ -14,6 +14,11 @@
14 14 {{else}}
15 15 <form method="post" action="/register" class="card-body flex flex-col gap-3">
16 16 <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
17 + {{if eq .RegistrationMode "confirm"}}
18 + <div class="card p-2.5 text-xs text-muted" style="background:hsl(var(--muted) / 0.35);">
19 + {{t .Lang "auth.register.pending_notice"}}
20 + </div>
21 + {{end}}
17 22 <div>
18 23 <label class="label" for="username">{{t .Lang "auth.username"}}</label>
19 24 <input id="username" name="username" type="text" class="input font-mono" value="{{.FormUsername}}" placeholder="polar" required autofocus pattern="^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$">
internal/web/views/pages/repo_settings.html
+26 -1
... ... @@ -7,8 +7,33 @@
7 7 <div class="card-header">
8 8 <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "repo.tab.settings"}}</span>
9 9 </div>
10 - <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" class="card-body flex flex-col gap-3">
10 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" enctype="multipart/form-data" class="card-body flex flex-col gap-3">
11 11 <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
12 + <input type="hidden" id="repo-avatar-data-input" name="avatar_data" value="">
13 + <input type="hidden" id="remove-repo-avatar-input" name="remove_avatar" value="false">
14 +
15 + <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));">
16 + <div id="repo-avatar-preview-wrap">
17 + {{avatar .Repo.OwnerUsername (or .Repo.AvatarURL .Repo.OwnerAvatarURL) "avatar-xl"}}
18 + </div>
19 + <div class="flex flex-col gap-1.5">
20 + <div class="text-sm font-medium">{{t .Lang "repo.avatar"}}</div>
21 + <div class="flex items-center gap-2 flex-wrap">
22 + <label class="btn btn-outline btn-sm" style="cursor:pointer;">
23 + {{icon "image" "size-3.5"}}
24 + <span>{{t .Lang "repo.avatar_upload"}}</span>
25 + <input id="repo-avatar-file-input" name="avatar_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;">
26 + </label>
27 + {{if .Repo.AvatarURL}}
28 + <button type="button" id="remove-repo-avatar-btn" class="btn btn-ghost btn-sm text-destructive">
29 + {{icon "trash" "size-3.5"}}
30 + <span>{{t .Lang "repo.avatar_remove"}}</span>
31 + </button>
32 + {{end}}
33 + </div>
34 + <div class="field-hint" style="margin:0;">{{t .Lang "repo.avatar_hint"}}</div>
35 + </div>
36 + </div>
12 37
13 38 <div>
14 39 <label class="label" for="description">{{t .Lang "repo.description"}}</label>
internal/web/views/pages/settings.html
+81 -0
... ... @@ -17,6 +17,8 @@
17 17 <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
18 18 <input type="hidden" id="avatar-data-input" name="avatar_data" value="">
19 19 <input type="hidden" id="remove-avatar-input" name="remove_avatar" value="false">
20 + <input type="hidden" id="background-data-input" name="background_data" value="">
21 + <input type="hidden" id="remove-background-input" name="remove_background" value="false">
20 22
21 23 <!-- Avatar Picker Row -->
22 24 <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));">
... ... @@ -42,6 +44,36 @@
42 44 </div>
43 45 </div>
44 46
47 + {{$bg := imageURL .CurrentUser.BackgroundURL}}
48 + <div class="flex flex-col gap-2 pb-3" style="border-bottom:1px solid hsl(var(--border));">
49 + <div class="flex items-center justify-between gap-2 flex-wrap">
50 + <div>
51 + <div class="text-sm font-medium">{{t .Lang "settings.background"}}</div>
52 + <div class="field-hint" style="margin:0;">{{t .Lang "settings.background_hint"}}</div>
53 + </div>
54 + <div class="flex items-center gap-2 flex-wrap">
55 + <label class="btn btn-outline btn-sm" style="cursor:pointer;">
56 + {{icon "image" "size-3.5"}}
57 + <span>{{t .Lang "settings.background_upload"}}</span>
58 + <input id="background-file-input" name="background_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;">
59 + </label>
60 + {{if $bg}}
61 + <button type="button" id="remove-background-btn" class="btn btn-ghost btn-sm text-destructive">
62 + {{icon "trash" "size-3.5"}}
63 + <span>{{t .Lang "settings.background_remove"}}</span>
64 + </button>
65 + {{end}}
66 + </div>
67 + </div>
68 + <div id="background-preview" class="background-preview{{if not $bg}} is-empty{{end}}">
69 + {{if $bg}}
70 + <img src="{{$bg}}" alt="">
71 + {{else}}
72 + <span class="background-preview-placeholder">{{icon "image" "size-5"}}</span>
73 + {{end}}
74 + </div>
75 + </div>
76 +
45 77 <div class="grid-2">
46 78 <div>
47 79 <label class="label">{{t .Lang "auth.username"}}</label>
... ... @@ -180,5 +212,54 @@
180 212 </div>
181 213 </form>
182 214 </section>
215 +
216 + <!-- Danger Zone -->
217 + <section id="danger" class="card mt-4" style="border-color:hsl(var(--destructive) / 0.45);">
218 + <div class="card-header" style="border-bottom-color:hsl(var(--destructive) / 0.2);">
219 + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "alert-circle" "size-4"}} {{t .Lang "settings.delete_account"}}</span>
220 + </div>
221 + <div class="card-body flex items-center justify-between flex-wrap gap-3">
222 + <div>
223 + <div class="font-medium text-sm">{{t .Lang "settings.delete_account"}}</div>
224 + <div class="text-xs text-muted">{{t .Lang "settings.delete_account_desc"}}</div>
225 + </div>
226 + <button type="button" class="btn btn-destructive btn-sm" data-dialog-open="delete-account-dialog">
227 + {{icon "trash" "size-3.5"}}
228 + <span>{{t .Lang "settings.delete_account"}}</span>
229 + </button>
230 + </div>
231 + </section>
232 +</div>
233 +
234 +<!-- Delete Account Confirmation -->
235 +<div id="delete-account-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "settings.delete_account"}}" aria-hidden="true">
236 + <div class="dialog-box">
237 + <div class="card-header">
238 + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "settings.delete_account"}}</span>
239 + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button>
240 + </div>
241 + <form method="post" action="/settings/delete">
242 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
243 + <div class="card-body flex flex-col gap-3">
244 + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "settings.delete_account_desc"}}</p>
245 + <div>
246 + <label class="label" for="confirm_delete_username">{{t .Lang "settings.delete_account_confirm"}} <strong class="font-mono" style="color:hsl(var(--foreground));">{{.CurrentUser.Username}}</strong></label>
247 + <input id="confirm_delete_username" type="text" name="confirm_username" class="input font-mono" autocomplete="off" required
248 + data-confirm-input="{{.CurrentUser.Username}}" data-confirm-button="confirm-delete-account-btn">
249 + </div>
250 + <div>
251 + <label class="label" for="delete_account_password">{{t .Lang "settings.delete_password"}}</label>
252 + <input id="delete_account_password" type="password" name="password" class="input" required autocomplete="current-password">
253 + </div>
254 + </div>
255 + <div class="card-footer flex items-center justify-end gap-2">
256 + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button>
257 + <button id="confirm-delete-account-btn" type="submit" class="btn btn-destructive btn-sm" disabled>
258 + {{icon "trash" "size-3.5"}}
259 + <span>{{t .Lang "settings.delete_account_button"}}</span>
260 + </button>
261 + </div>
262 + </form>
263 + </div>
183 264 </div>
184 265 {{end}}
internal/web/views/partials/components.html
+1 -1
... ... @@ -45,7 +45,7 @@
45 45 {{define "repo_header"}}
46 46 <div class="repo-banner mb-3{{if .Repo.ForkedFromRepoID}} has-subrow{{end}}">
47 47 <div class="repo-banner-main">
48 - <a href="/{{.Repo.OwnerUsername}}" class="repo-avatar-link">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a>
48 + <a href="/{{.Repo.OwnerUsername}}" class="repo-avatar-link">{{avatar .Repo.OwnerUsername (or .Repo.AvatarURL .Repo.OwnerAvatarURL) "repo-avatar"}}</a>
49 49 <div class="min-w-0 repo-head-block">
50 50 <div class="repo-head-row">
51 51 <nav class="breadcrumb" aria-label="Repository breadcrumb">
internal/web/views/views.go
+11 -0
... ... @@ -50,6 +50,7 @@ func NewEngine() (*Engine, error) {
50 50 "formatSize": formatBytes,
51 51 "avatarInitial": avatarInitial,
52 52 "avatar": renderAvatar,
53 + "imageURL": renderImageURL,
53 54 "renderMD": func(s string) template.HTML { return RenderMarkdown([]byte(s)) },
54 55 "add": func(a, b int) int { return a + b },
55 56 "sub": func(a, b int) int { return a - b },
... ... @@ -130,6 +131,16 @@ func avatarInitial(username string) string {
130 131 return strings.ToUpper(string(runes[0]))
131 132 }
132 133
134 +func renderImageURL(value string) template.URL {
135 + value = strings.TrimSpace(value)
136 + for _, prefix := range []string{"data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,", "/", "https://", "http://"} {
137 + if strings.HasPrefix(value, prefix) {
138 + return template.URL(value)
139 + }
140 + }
141 + return ""
142 +}
143 +
133 144 func renderAvatar(args ...any) template.HTML {
134 145 if len(args) == 0 {
135 146 return template.HTML(`<span class="avatar">?</span>`)