FossilRepo

Add multi-remote sync UI, user profile page, personal access tokens Multi-remote sync: sync page redesigned as hub showing all Git mirrors per repo as cards with direction badges, schedule, status, and per-mirror Run/Edit/Delete actions. Add/edit form improved with sync direction, branch mapping, and content options. 30 tests. User profile: unified page at /auth/profile/ with display name, @handle, bio, location, website. Consolidates SSH keys, notification prefs, and personal access tokens into one view. Profile link added to top nav dropdown. 33 tests. Personal access tokens: user-scoped tokens (frp_ prefix) with hash-only storage, configurable scopes (read/write/admin), expiry, revocation tracking. Show-once page with copy button on creation.

lmata 2026-04-07 15:42 trunk
Commit 2ea5fbeb0fd199151d9caa13f930c17fc9b56d4cb1b586b5803260ada81a02c2
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -0,0 +1,20 @@
1
+from django.contrib import admin
2
+
3
+from .models import PersonalAccessToken, UserProfile
4
+
5
+
6
+@admin.register(UserProfile)
7
+class UserProfileAdmin(admin.ModelAdmin):
8
+ list_display = ("user", "handle", "location")
9
+ search_fields = ("user__username", "handle", "location")
10
+ raw_id_fields = ("user",)
11
+ readonly_fields = ("user",)
12
+
13
+
14
+@admin.register(PersonalAccessToken)
15
+class PersonalAccessTokenAdmin(admin.ModelAdmin):
16
+ list_display = ("name", "user", "token_prefix", "scopes", "created_at", "expires_at", "last_used_at", "revoked_at")
17
+ list_filter = ("scopes",)
18
+ search_fields = ("name", "user__username", "token_prefix")
19
+ raw_id_fields = ("user",)
20
+ readonly_fields = ("token_hash", "token_prefix", "created_at", "last_used_at")
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -0,0 +1,20 @@
1 from django.contrib import admin
2
3 from .models import PersonalAccessToken, UserProfile
4
5
6 @admin.register(UserProfile)
7 class UserProfileAdmin(admin.ModelAdmin):
8 list_display = ("user", "handle", "location")
9 search_fields = ("user__username", "handle", "location")
10 raw_id_fields = ("user",)
11 readonly_fields = ("user",)
12
13
14 @admin.register(PersonalAccessToken)
15 class PersonalAccessTokenAdmin(admin.ModelAdmin):
16 list_display = ("name", "user", "token_prefix", "scopes", "created_at", "expires_at", "last_used_at", "revoked_at")
17 list_filter = ("scopes",)
18 search_fields = ("name", "user__username", "token_prefix")
19 raw_id_fields = ("user",)
20 readonly_fields = ("token_hash", "token_prefix", "created_at", "last_used_at")
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,92 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 15:40
2
+
3
+import django.db.models.deletion
4
+from django.conf import settings
5
+from django.db import migrations, models
6
+
7
+
8
+class Migration(migrations.Migration):
9
+ initial = True
10
+
11
+ dependencies = [
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="PersonalAccessToken",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigAutoField(
22
+ auto_created=True,
23
+ primary_key=True,
24
+ serialize=False,
25
+ verbose_name="ID",
26
+ ),
27
+ ),
28
+ ("name", models.CharField(max_length=200)),
29
+ ("token_hash", models.CharField(max_length=64, unique=True)),
30
+ ("token_prefix", models.CharField(max_length=12)),
31
+ (
32
+ "scopes",
33
+ models.CharField(
34
+ default="read",
35
+ help_text="Comma-separated: read, write, admin",
36
+ max_length=500,
37
+ ),
38
+ ),
39
+ ("expires_at", models.DateTimeField(blank=True, null=True)),
40
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
41
+ ("created_at", models.DateTimeField(auto_now_add=True)),
42
+ ("revoked_at", models.DateTimeField(blank=True, null=True)),
43
+ (
44
+ "user",
45
+ models.ForeignKey(
46
+ on_delete=django.db.models.deletion.CASCADE,
47
+ related_name="personal_tokens",
48
+ to=settings.AUTH_USER_MODEL,
49
+ ),
50
+ ),
51
+ ],
52
+ options={
53
+ "ordering": ["-created_at"],
54
+ },
55
+ ),
56
+ migrations.CreateModel(
57
+ name="UserProfile",
58
+ fields=[
59
+ (
60
+ "id",
61
+ models.BigAutoField(
62
+ auto_created=True,
63
+ primary_key=True,
64
+ serialize=False,
65
+ verbose_name="ID",
66
+ ),
67
+ ),
68
+ (
69
+ "handle",
70
+ models.CharField(
71
+ blank=True,
72
+ default=None,
73
+ help_text="@handle for mentions (alphanumeric and hyphens only)",
74
+ max_length=50,
75
+ null=True,
76
+ unique=True,
77
+ ),
78
+ ),
79
+ ("bio", models.TextField(blank=True, default="", max_length=500)),
80
+ ("location", models.CharField(blank=True, default="", max_length=100)),
81
+ ("website", models.URLField(blank=True, default="")),
82
+ (
83
+ "user",
84
+ models.OneToOneField(
85
+ on_delete=django.db.models.deletion.CASCADE,
86
+ related_name="profile",
87
+ to=settings.AUTH_USER_MODEL,
88
+ ),
89
+ ),
90
+ ],
91
+ ),
92
+ ]
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,92 @@
1 # Generated by Django 5.2.12 on 2026-04-07 15:40
2
3 import django.db.models.deletion
4 from django.conf import settings
5 from django.db import migrations, models
6
7
8 class Migration(migrations.Migration):
9 initial = True
10
11 dependencies = [
12 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 ]
14
15 operations = [
16 migrations.CreateModel(
17 name="PersonalAccessToken",
18 fields=[
19 (
20 "id",
21 models.BigAutoField(
22 auto_created=True,
23 primary_key=True,
24 serialize=False,
25 verbose_name="ID",
26 ),
27 ),
28 ("name", models.CharField(max_length=200)),
29 ("token_hash", models.CharField(max_length=64, unique=True)),
30 ("token_prefix", models.CharField(max_length=12)),
31 (
32 "scopes",
33 models.CharField(
34 default="read",
35 help_text="Comma-separated: read, write, admin",
36 max_length=500,
37 ),
38 ),
39 ("expires_at", models.DateTimeField(blank=True, null=True)),
40 ("last_used_at", models.DateTimeField(blank=True, null=True)),
41 ("created_at", models.DateTimeField(auto_now_add=True)),
42 ("revoked_at", models.DateTimeField(blank=True, null=True)),
43 (
44 "user",
45 models.ForeignKey(
46 on_delete=django.db.models.deletion.CASCADE,
47 related_name="personal_tokens",
48 to=settings.AUTH_USER_MODEL,
49 ),
50 ),
51 ],
52 options={
53 "ordering": ["-created_at"],
54 },
55 ),
56 migrations.CreateModel(
57 name="UserProfile",
58 fields=[
59 (
60 "id",
61 models.BigAutoField(
62 auto_created=True,
63 primary_key=True,
64 serialize=False,
65 verbose_name="ID",
66 ),
67 ),
68 (
69 "handle",
70 models.CharField(
71 blank=True,
72 default=None,
73 help_text="@handle for mentions (alphanumeric and hyphens only)",
74 max_length=50,
75 null=True,
76 unique=True,
77 ),
78 ),
79 ("bio", models.TextField(blank=True, default="", max_length=500)),
80 ("location", models.CharField(blank=True, default="", max_length=100)),
81 ("website", models.URLField(blank=True, default="")),
82 (
83 "user",
84 models.OneToOneField(
85 on_delete=django.db.models.deletion.CASCADE,
86 related_name="profile",
87 to=settings.AUTH_USER_MODEL,
88 ),
89 ),
90 ],
91 ),
92 ]
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -0,0 +1,88 @@
1
+"""User profile and personal access token models.
2
+
3
+UserProfile extends Django's built-in User with optional profile fields.
4
+PersonalAccessToken provides user-scoped tokens for API/CLI authentication,
5
+separate from project-scoped APITokens.
6
+"""
7
+
8
+import hashlib
9
+import re
10
+import secrets
11
+
12
+from django.contrib.auth.models import User
13
+from django.db import models
14
+from django.utils import timezone
15
+
16
+
17
+class UserProfile(models.Model):
18
+ """Extended profile information for users."""
19
+
20
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
21
+ handle = models.CharField(
22
+ max_length=50,
23
+ blank=True,
24
+ null=True,
25
+ default=None,
26
+ unique=True,
27
+ help_text="@handle for mentions (alphanumeric and hyphens only)",
28
+ )
29
+ bio = models.TextField(blank=True, default="", max_length=500)
30
+ location = models.CharField(max_length=100, blank=True, default="")
31
+ website = models.URLField(blank=True, default="")
32
+
33
+ def __str__(self):
34
+ return f"@{self.handle or self.user.username}"
35
+
36
+ @staticmethod
37
+ def sanitize_handle(raw: str) -> str:
38
+ """Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens."""
39
+ cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip())
40
+ return cleaned.strip("-")
41
+
42
+
43
+class PersonalAccessToken(models.Model):
44
+ """User-scoped personal access token for API/CLI authentication.
45
+
46
+ Tokens are stored as SHA-256 hashes -- the raw value is shown once on
47
+ creation and never stored in plaintext.
48
+ """
49
+
50
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens")
51
+ name = models.CharField(max_length=200)
52
+ token_hash = models.CharField(max_length=64, unique=True)
53
+ token_prefix = models.CharField(max_length=12)
54
+ scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin")
55
+ expires_at = models.DateTimeField(null=True, blank=True)
56
+ last_used_at = models.DateTimeField(null=True, blank=True)
57
+ created_at = models.DateTimeField(auto_now_add=True)
58
+ revoked_at = models.DateTimeField(null=True, blank=True)
59
+
60
+ class Meta:
61
+ ordering = ["-created_at"]
62
+
63
+ @staticmethod
64
+ def generate():
65
+ """Generate a new token. Returns (raw_token, token_hash, prefix)."""
66
+ raw = f"frp_{secrets.token_urlsafe(32)}"
67
+ hash_val = hashlib.sha256(raw.encode()).hexdigest()
68
+ prefix = raw[:12]
69
+ return raw, hash_val, prefix
70
+
71
+ @staticmethod
72
+ def hash_token(raw_token):
73
+ return hashlib.sha256(raw_token.encode()).hexdigest()
74
+
75
+ @property
76
+ def is_expired(self):
77
+ return bool(self.expires_at and self.expires_at < timezone.now())
78
+
79
+ @property
80
+ def is_revoked(self):
81
+ return self.revoked_at is not None
82
+
83
+ @property
84
+ def is_active(self):
85
+ return not self.is_expired and not self.is_revoked
86
+
87
+ def __str__(self):
88
+ return f"{self.name} ({self.token_prefix}...)"
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -0,0 +1,88 @@
1 """User profile and personal access token models.
2
3 UserProfile extends Django's built-in User with optional profile fields.
4 PersonalAccessToken provides user-scoped tokens for API/CLI authentication,
5 separate from project-scoped APITokens.
6 """
7
8 import hashlib
9 import re
10 import secrets
11
12 from django.contrib.auth.models import User
13 from django.db import models
14 from django.utils import timezone
15
16
17 class UserProfile(models.Model):
18 """Extended profile information for users."""
19
20 user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
21 handle = models.CharField(
22 max_length=50,
23 blank=True,
24 null=True,
25 default=None,
26 unique=True,
27 help_text="@handle for mentions (alphanumeric and hyphens only)",
28 )
29 bio = models.TextField(blank=True, default="", max_length=500)
30 location = models.CharField(max_length=100, blank=True, default="")
31 website = models.URLField(blank=True, default="")
32
33 def __str__(self):
34 return f"@{self.handle or self.user.username}"
35
36 @staticmethod
37 def sanitize_handle(raw: str) -> str:
38 """Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens."""
39 cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip())
40 return cleaned.strip("-")
41
42
43 class PersonalAccessToken(models.Model):
44 """User-scoped personal access token for API/CLI authentication.
45
46 Tokens are stored as SHA-256 hashes -- the raw value is shown once on
47 creation and never stored in plaintext.
48 """
49
50 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens")
51 name = models.CharField(max_length=200)
52 token_hash = models.CharField(max_length=64, unique=True)
53 token_prefix = models.CharField(max_length=12)
54 scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin")
55 expires_at = models.DateTimeField(null=True, blank=True)
56 last_used_at = models.DateTimeField(null=True, blank=True)
57 created_at = models.DateTimeField(auto_now_add=True)
58 revoked_at = models.DateTimeField(null=True, blank=True)
59
60 class Meta:
61 ordering = ["-created_at"]
62
63 @staticmethod
64 def generate():
65 """Generate a new token. Returns (raw_token, token_hash, prefix)."""
66 raw = f"frp_{secrets.token_urlsafe(32)}"
67 hash_val = hashlib.sha256(raw.encode()).hexdigest()
68 prefix = raw[:12]
69 return raw, hash_val, prefix
70
71 @staticmethod
72 def hash_token(raw_token):
73 return hashlib.sha256(raw_token.encode()).hexdigest()
74
75 @property
76 def is_expired(self):
77 return bool(self.expires_at and self.expires_at < timezone.now())
78
79 @property
80 def is_revoked(self):
81 return self.revoked_at is not None
82
83 @property
84 def is_active(self):
85 return not self.is_expired and not self.is_revoked
86
87 def __str__(self):
88 return f"{self.name} ({self.token_prefix}...)"
--- accounts/tests.py
+++ accounts/tests.py
@@ -1,8 +1,10 @@
11
import pytest
22
from django.urls import reverse
33
4
+from accounts.models import PersonalAccessToken, UserProfile
5
+
46
57
@pytest.mark.django_db
68
class TestLogin:
79
def test_login_page_renders(self, client):
810
response = client.get(reverse("accounts:login"))
@@ -42,5 +44,265 @@
4244
assert response.status_code == 302 # redirected to login
4345
4446
def test_logout_rejects_get(self, admin_client):
4547
response = admin_client.get(reverse("accounts:logout"))
4648
assert response.status_code == 405
49
+
50
+
51
+# ---------------------------------------------------------------------------
52
+# Profile views
53
+# ---------------------------------------------------------------------------
54
+
55
+
56
+@pytest.mark.django_db
57
+class TestProfile:
58
+ def test_profile_page_renders(self, admin_client, admin_user):
59
+ response = admin_client.get(reverse("accounts:profile"))
60
+ assert response.status_code == 200
61
+ assert b"Profile Info" in response.content
62
+ assert b"SSH Keys" in response.content
63
+ assert b"Personal Access Tokens" in response.content
64
+
65
+ def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user):
66
+ assert not UserProfile.objects.filter(user=admin_user).exists()
67
+ admin_client.get(reverse("accounts:profile"))
68
+ assert UserProfile.objects.filter(user=admin_user).exists()
69
+
70
+ def test_profile_requires_login(self, client):
71
+ response = client.get(reverse("accounts:profile"))
72
+ assert response.status_code == 302
73
+ assert "/auth/login/" in response.url
74
+
75
+ def test_profile_top_level_redirect(self, admin_client):
76
+ response = admin_client.get("/profile/")
77
+ assert response.status_code == 302
78
+ assert "/auth/profile/" in response.url
79
+
80
+
81
+@pytest.mark.django_db
82
+class TestProfileEdit:
83
+ def test_edit_page_renders(self, admin_client, admin_user):
84
+ response = admin_client.get(reverse("accounts:profile_edit"))
85
+ assert response.status_code == 200
86
+ assert b"Edit Profile" in response.content
87
+
88
+ def test_edit_updates_user_fields(self, admin_client, admin_user):
89
+ response = admin_client.post(
90
+ reverse("accounts:profile_edit"),
91
+ {
92
+ "first_name": "Alice",
93
+ "last_name": "Smith",
94
+ "email": "[email protected]",
95
+ "handle": "alice-s",
96
+ "bio": "Hello world",
97
+ "location": "NYC",
98
+ "website": "https://alice.dev",
99
+ },
100
+ )
101
+ assert response.status_code == 302
102
+ admin_user.refresh_from_db()
103
+ assert admin_user.first_name == "Alice"
104
+ assert admin_user.last_name == "Smith"
105
+ assert admin_user.email == "[email protected]"
106
+ profile = UserProfile.objects.get(user=admin_user)
107
+ assert profile.handle == "alice-s"
108
+ assert profile.bio == "Hello world"
109
+ assert profile.location == "NYC"
110
+ assert profile.website == "https://alice.dev"
111
+
112
+ def test_edit_sanitizes_handle(self, admin_client, admin_user):
113
+ admin_client.post(
114
+ reverse("accounts:profile_edit"),
115
+ {"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""},
116
+ )
117
+ profile = UserProfile.objects.get(user=admin_user)
118
+ assert profile.handle == "uppercasestuff"
119
+
120
+ def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user):
121
+ # Create a profile with handle for viewer_user
122
+ UserProfile.objects.create(user=viewer_user, handle="taken-handle")
123
+ response = admin_client.post(
124
+ reverse("accounts:profile_edit"),
125
+ {"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""},
126
+ )
127
+ assert response.status_code == 200 # re-renders form with error
128
+ assert b"already taken" in response.content
129
+
130
+ def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user):
131
+ admin_client.post(
132
+ reverse("accounts:profile_edit"),
133
+ {"handle": "", "first_name": "", "last_name": "", "email": ""},
134
+ )
135
+ profile = UserProfile.objects.get(user=admin_user)
136
+ assert profile.handle is None
137
+
138
+ def test_edit_requires_login(self, client):
139
+ response = client.get(reverse("accounts:profile_edit"))
140
+ assert response.status_code == 302
141
+ assert "/auth/login/" in response.url
142
+
143
+
144
+@pytest.mark.django_db
145
+class TestPersonalAccessTokenCreate:
146
+ def test_create_form_renders(self, admin_client):
147
+ response = admin_client.get(reverse("accounts:profile_token_create"))
148
+ assert response.status_code == 200
149
+ assert b"Generate Personal Access Token" in response.content
150
+
151
+ def test_create_token_shows_raw_once(self, admin_client, admin_user):
152
+ response = admin_client.post(
153
+ reverse("accounts:profile_token_create"),
154
+ {"name": "CI Token", "scopes": "read,write"},
155
+ )
156
+ assert response.status_code == 200
157
+ assert b"frp_" in response.content
158
+ assert b"will not be shown again" in response.content
159
+ token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token")
160
+ assert token.scopes == "read,write"
161
+ assert token.token_prefix.startswith("frp_")
162
+
163
+ def test_create_token_default_scope_is_read(self, admin_client, admin_user):
164
+ admin_client.post(
165
+ reverse("accounts:profile_token_create"),
166
+ {"name": "Default Token", "scopes": ""},
167
+ )
168
+ token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token")
169
+ assert token.scopes == "read"
170
+
171
+ def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user):
172
+ admin_client.post(
173
+ reverse("accounts:profile_token_create"),
174
+ {"name": "Bad Token", "scopes": "delete,destroy"},
175
+ )
176
+ token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token")
177
+ assert token.scopes == "read" # falls back to read
178
+
179
+ def test_create_token_requires_name(self, admin_client, admin_user):
180
+ response = admin_client.post(
181
+ reverse("accounts:profile_token_create"),
182
+ {"name": "", "scopes": "read"},
183
+ )
184
+ assert response.status_code == 200
185
+ assert b"Token name is required" in response.content
186
+ assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0
187
+
188
+ def test_create_token_requires_login(self, client):
189
+ response = client.get(reverse("accounts:profile_token_create"))
190
+ assert response.status_code == 302
191
+ assert "/auth/login/" in response.url
192
+
193
+
194
+@pytest.mark.django_db
195
+class TestPersonalAccessTokenRevoke:
196
+ def test_revoke_token(self, admin_client, admin_user):
197
+ raw, token_hash, prefix = PersonalAccessToken.generate()
198
+ token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix)
199
+ response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
200
+ assert response.status_code == 302
201
+ token.refresh_from_db()
202
+ assert token.revoked_at is not None
203
+
204
+ def test_revoke_token_htmx(self, admin_client, admin_user):
205
+ raw, token_hash, prefix = PersonalAccessToken.generate()
206
+ PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix)
207
+ response = admin_client.post(
208
+ reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}),
209
+ HTTP_HX_REQUEST="true",
210
+ )
211
+ assert response.status_code == 200
212
+ assert response["HX-Redirect"] == "/auth/profile/"
213
+
214
+ def test_revoke_token_wrong_user(self, admin_client, viewer_user):
215
+ """Cannot revoke another user's token."""
216
+ raw, token_hash, prefix = PersonalAccessToken.generate()
217
+ PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix)
218
+ response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
219
+ assert response.status_code == 404
220
+
221
+ def test_revoke_already_revoked(self, admin_client, admin_user):
222
+ from django.utils import timezone
223
+
224
+ raw, token_hash, prefix = PersonalAccessToken.generate()
225
+ PersonalAccessToken.objects.create(
226
+ user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now()
227
+ )
228
+ response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
229
+ assert response.status_code == 404
230
+
231
+ def test_revoke_requires_post(self, admin_client, admin_user):
232
+ raw, token_hash, prefix = PersonalAccessToken.generate()
233
+ PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix)
234
+ response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
235
+ assert response.status_code == 405
236
+
237
+ def test_revoke_requires_login(self, client):
238
+ response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"}))
239
+ assert response.status_code == 302
240
+ assert "/auth/login/" in response.url
241
+
242
+
243
+# ---------------------------------------------------------------------------
244
+# Model unit tests
245
+# ---------------------------------------------------------------------------
246
+
247
+
248
+@pytest.mark.django_db
249
+class TestUserProfileModel:
250
+ def test_str_with_handle(self, admin_user):
251
+ profile = UserProfile.objects.create(user=admin_user, handle="testhandle")
252
+ assert str(profile) == "@testhandle"
253
+
254
+ def test_str_without_handle(self, admin_user):
255
+ profile = UserProfile.objects.create(user=admin_user)
256
+ assert str(profile) == "@admin"
257
+
258
+ def test_sanitize_handle(self):
259
+ assert UserProfile.sanitize_handle("Hello World!") == "helloworld"
260
+ assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle"
261
+ assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase"
262
+ assert UserProfile.sanitize_handle("") == ""
263
+
264
+ def test_multiple_null_handles_allowed(self, admin_user, viewer_user):
265
+ """Multiple profiles with handle=None should not violate unique constraint."""
266
+ UserProfile.objects.create(user=admin_user, handle=None)
267
+ UserProfile.objects.create(user=viewer_user, handle=None)
268
+ assert UserProfile.objects.filter(handle__isnull=True).count() == 2
269
+
270
+
271
+@pytest.mark.django_db
272
+class TestPersonalAccessTokenModel:
273
+ def test_generate_returns_triple(self):
274
+ raw, hash_val, prefix = PersonalAccessToken.generate()
275
+ assert raw.startswith("frp_")
276
+ assert len(hash_val) == 64
277
+ assert prefix == raw[:12]
278
+
279
+ def test_hash_token_matches_generate(self):
280
+ raw, expected_hash, _ = PersonalAccessToken.generate()
281
+ assert PersonalAccessToken.hash_token(raw) == expected_hash
282
+
283
+ def test_is_expired(self, admin_user):
284
+ from django.utils import timezone
285
+
286
+ token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1))
287
+ assert token.is_expired is True
288
+
289
+ def test_is_not_expired(self, admin_user):
290
+ from django.utils import timezone
291
+
292
+ token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1))
293
+ assert token.is_expired is False
294
+
295
+ def test_is_active(self, admin_user):
296
+ token = PersonalAccessToken(user=admin_user)
297
+ assert token.is_active is True
298
+
299
+ def test_is_revoked(self, admin_user):
300
+ from django.utils import timezone
301
+
302
+ token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now())
303
+ assert token.is_active is False
304
+ assert token.is_revoked is True
305
+
306
+ def test_str(self, admin_user):
307
+ token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345")
308
+ assert str(token) == "Test (frp_abc12345...)"
47309
--- accounts/tests.py
+++ accounts/tests.py
@@ -1,8 +1,10 @@
1 import pytest
2 from django.urls import reverse
3
 
 
4
5 @pytest.mark.django_db
6 class TestLogin:
7 def test_login_page_renders(self, client):
8 response = client.get(reverse("accounts:login"))
@@ -42,5 +44,265 @@
42 assert response.status_code == 302 # redirected to login
43
44 def test_logout_rejects_get(self, admin_client):
45 response = admin_client.get(reverse("accounts:logout"))
46 assert response.status_code == 405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
--- accounts/tests.py
+++ accounts/tests.py
@@ -1,8 +1,10 @@
1 import pytest
2 from django.urls import reverse
3
4 from accounts.models import PersonalAccessToken, UserProfile
5
6
7 @pytest.mark.django_db
8 class TestLogin:
9 def test_login_page_renders(self, client):
10 response = client.get(reverse("accounts:login"))
@@ -42,5 +44,265 @@
44 assert response.status_code == 302 # redirected to login
45
46 def test_logout_rejects_get(self, admin_client):
47 response = admin_client.get(reverse("accounts:logout"))
48 assert response.status_code == 405
49
50
51 # ---------------------------------------------------------------------------
52 # Profile views
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.mark.django_db
57 class TestProfile:
58 def test_profile_page_renders(self, admin_client, admin_user):
59 response = admin_client.get(reverse("accounts:profile"))
60 assert response.status_code == 200
61 assert b"Profile Info" in response.content
62 assert b"SSH Keys" in response.content
63 assert b"Personal Access Tokens" in response.content
64
65 def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user):
66 assert not UserProfile.objects.filter(user=admin_user).exists()
67 admin_client.get(reverse("accounts:profile"))
68 assert UserProfile.objects.filter(user=admin_user).exists()
69
70 def test_profile_requires_login(self, client):
71 response = client.get(reverse("accounts:profile"))
72 assert response.status_code == 302
73 assert "/auth/login/" in response.url
74
75 def test_profile_top_level_redirect(self, admin_client):
76 response = admin_client.get("/profile/")
77 assert response.status_code == 302
78 assert "/auth/profile/" in response.url
79
80
81 @pytest.mark.django_db
82 class TestProfileEdit:
83 def test_edit_page_renders(self, admin_client, admin_user):
84 response = admin_client.get(reverse("accounts:profile_edit"))
85 assert response.status_code == 200
86 assert b"Edit Profile" in response.content
87
88 def test_edit_updates_user_fields(self, admin_client, admin_user):
89 response = admin_client.post(
90 reverse("accounts:profile_edit"),
91 {
92 "first_name": "Alice",
93 "last_name": "Smith",
94 "email": "[email protected]",
95 "handle": "alice-s",
96 "bio": "Hello world",
97 "location": "NYC",
98 "website": "https://alice.dev",
99 },
100 )
101 assert response.status_code == 302
102 admin_user.refresh_from_db()
103 assert admin_user.first_name == "Alice"
104 assert admin_user.last_name == "Smith"
105 assert admin_user.email == "[email protected]"
106 profile = UserProfile.objects.get(user=admin_user)
107 assert profile.handle == "alice-s"
108 assert profile.bio == "Hello world"
109 assert profile.location == "NYC"
110 assert profile.website == "https://alice.dev"
111
112 def test_edit_sanitizes_handle(self, admin_client, admin_user):
113 admin_client.post(
114 reverse("accounts:profile_edit"),
115 {"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""},
116 )
117 profile = UserProfile.objects.get(user=admin_user)
118 assert profile.handle == "uppercasestuff"
119
120 def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user):
121 # Create a profile with handle for viewer_user
122 UserProfile.objects.create(user=viewer_user, handle="taken-handle")
123 response = admin_client.post(
124 reverse("accounts:profile_edit"),
125 {"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""},
126 )
127 assert response.status_code == 200 # re-renders form with error
128 assert b"already taken" in response.content
129
130 def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user):
131 admin_client.post(
132 reverse("accounts:profile_edit"),
133 {"handle": "", "first_name": "", "last_name": "", "email": ""},
134 )
135 profile = UserProfile.objects.get(user=admin_user)
136 assert profile.handle is None
137
138 def test_edit_requires_login(self, client):
139 response = client.get(reverse("accounts:profile_edit"))
140 assert response.status_code == 302
141 assert "/auth/login/" in response.url
142
143
144 @pytest.mark.django_db
145 class TestPersonalAccessTokenCreate:
146 def test_create_form_renders(self, admin_client):
147 response = admin_client.get(reverse("accounts:profile_token_create"))
148 assert response.status_code == 200
149 assert b"Generate Personal Access Token" in response.content
150
151 def test_create_token_shows_raw_once(self, admin_client, admin_user):
152 response = admin_client.post(
153 reverse("accounts:profile_token_create"),
154 {"name": "CI Token", "scopes": "read,write"},
155 )
156 assert response.status_code == 200
157 assert b"frp_" in response.content
158 assert b"will not be shown again" in response.content
159 token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token")
160 assert token.scopes == "read,write"
161 assert token.token_prefix.startswith("frp_")
162
163 def test_create_token_default_scope_is_read(self, admin_client, admin_user):
164 admin_client.post(
165 reverse("accounts:profile_token_create"),
166 {"name": "Default Token", "scopes": ""},
167 )
168 token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token")
169 assert token.scopes == "read"
170
171 def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user):
172 admin_client.post(
173 reverse("accounts:profile_token_create"),
174 {"name": "Bad Token", "scopes": "delete,destroy"},
175 )
176 token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token")
177 assert token.scopes == "read" # falls back to read
178
179 def test_create_token_requires_name(self, admin_client, admin_user):
180 response = admin_client.post(
181 reverse("accounts:profile_token_create"),
182 {"name": "", "scopes": "read"},
183 )
184 assert response.status_code == 200
185 assert b"Token name is required" in response.content
186 assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0
187
188 def test_create_token_requires_login(self, client):
189 response = client.get(reverse("accounts:profile_token_create"))
190 assert response.status_code == 302
191 assert "/auth/login/" in response.url
192
193
194 @pytest.mark.django_db
195 class TestPersonalAccessTokenRevoke:
196 def test_revoke_token(self, admin_client, admin_user):
197 raw, token_hash, prefix = PersonalAccessToken.generate()
198 token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix)
199 response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
200 assert response.status_code == 302
201 token.refresh_from_db()
202 assert token.revoked_at is not None
203
204 def test_revoke_token_htmx(self, admin_client, admin_user):
205 raw, token_hash, prefix = PersonalAccessToken.generate()
206 PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix)
207 response = admin_client.post(
208 reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}),
209 HTTP_HX_REQUEST="true",
210 )
211 assert response.status_code == 200
212 assert response["HX-Redirect"] == "/auth/profile/"
213
214 def test_revoke_token_wrong_user(self, admin_client, viewer_user):
215 """Cannot revoke another user's token."""
216 raw, token_hash, prefix = PersonalAccessToken.generate()
217 PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix)
218 response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
219 assert response.status_code == 404
220
221 def test_revoke_already_revoked(self, admin_client, admin_user):
222 from django.utils import timezone
223
224 raw, token_hash, prefix = PersonalAccessToken.generate()
225 PersonalAccessToken.objects.create(
226 user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now()
227 )
228 response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
229 assert response.status_code == 404
230
231 def test_revoke_requires_post(self, admin_client, admin_user):
232 raw, token_hash, prefix = PersonalAccessToken.generate()
233 PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix)
234 response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
235 assert response.status_code == 405
236
237 def test_revoke_requires_login(self, client):
238 response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"}))
239 assert response.status_code == 302
240 assert "/auth/login/" in response.url
241
242
243 # ---------------------------------------------------------------------------
244 # Model unit tests
245 # ---------------------------------------------------------------------------
246
247
248 @pytest.mark.django_db
249 class TestUserProfileModel:
250 def test_str_with_handle(self, admin_user):
251 profile = UserProfile.objects.create(user=admin_user, handle="testhandle")
252 assert str(profile) == "@testhandle"
253
254 def test_str_without_handle(self, admin_user):
255 profile = UserProfile.objects.create(user=admin_user)
256 assert str(profile) == "@admin"
257
258 def test_sanitize_handle(self):
259 assert UserProfile.sanitize_handle("Hello World!") == "helloworld"
260 assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle"
261 assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase"
262 assert UserProfile.sanitize_handle("") == ""
263
264 def test_multiple_null_handles_allowed(self, admin_user, viewer_user):
265 """Multiple profiles with handle=None should not violate unique constraint."""
266 UserProfile.objects.create(user=admin_user, handle=None)
267 UserProfile.objects.create(user=viewer_user, handle=None)
268 assert UserProfile.objects.filter(handle__isnull=True).count() == 2
269
270
271 @pytest.mark.django_db
272 class TestPersonalAccessTokenModel:
273 def test_generate_returns_triple(self):
274 raw, hash_val, prefix = PersonalAccessToken.generate()
275 assert raw.startswith("frp_")
276 assert len(hash_val) == 64
277 assert prefix == raw[:12]
278
279 def test_hash_token_matches_generate(self):
280 raw, expected_hash, _ = PersonalAccessToken.generate()
281 assert PersonalAccessToken.hash_token(raw) == expected_hash
282
283 def test_is_expired(self, admin_user):
284 from django.utils import timezone
285
286 token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1))
287 assert token.is_expired is True
288
289 def test_is_not_expired(self, admin_user):
290 from django.utils import timezone
291
292 token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1))
293 assert token.is_expired is False
294
295 def test_is_active(self, admin_user):
296 token = PersonalAccessToken(user=admin_user)
297 assert token.is_active is True
298
299 def test_is_revoked(self, admin_user):
300 from django.utils import timezone
301
302 token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now())
303 assert token.is_active is False
304 assert token.is_revoked is True
305
306 def test_str(self, admin_user):
307 token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345")
308 assert str(token) == "Test (frp_abc12345...)"
309
--- accounts/urls.py
+++ accounts/urls.py
@@ -8,6 +8,11 @@
88
path("login/", views.login_view, name="login"),
99
path("logout/", views.logout_view, name="logout"),
1010
path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
1111
path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
1212
path("notifications/", views.notification_preferences, name="notification_prefs"),
13
+ # Unified profile
14
+ path("profile/", views.profile, name="profile"),
15
+ path("profile/edit/", views.profile_edit, name="profile_edit"),
16
+ path("profile/tokens/create/", views.profile_token_create, name="profile_token_create"),
17
+ path("profile/tokens/<str:guid>/revoke/", views.profile_token_revoke, name="profile_token_revoke"),
1318
]
1419
--- accounts/urls.py
+++ accounts/urls.py
@@ -8,6 +8,11 @@
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
11 path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
12 path("notifications/", views.notification_preferences, name="notification_prefs"),
 
 
 
 
 
13 ]
14
--- accounts/urls.py
+++ accounts/urls.py
@@ -8,6 +8,11 @@
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
11 path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
12 path("notifications/", views.notification_preferences, name="notification_prefs"),
13 # Unified profile
14 path("profile/", views.profile, name="profile"),
15 path("profile/edit/", views.profile_edit, name="profile_edit"),
16 path("profile/tokens/create/", views.profile_token_create, name="profile_token_create"),
17 path("profile/tokens/<str:guid>/revoke/", views.profile_token_revoke, name="profile_token_revoke"),
18 ]
19
--- accounts/views.py
+++ accounts/views.py
@@ -8,10 +8,11 @@
88
from django.utils.http import url_has_allowed_host_and_scheme
99
from django.views.decorators.http import require_POST
1010
from django_ratelimit.decorators import ratelimit
1111
1212
from .forms import LoginForm
13
+from .models import PersonalAccessToken, UserProfile
1314
1415
# Allowed SSH key type prefixes
1516
_SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
1617
1718
@@ -219,5 +220,120 @@
219220
return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
220221
221222
return redirect("accounts:notification_prefs")
222223
223224
return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
225
+
226
+
227
+# ---------------------------------------------------------------------------
228
+# Unified profile
229
+# ---------------------------------------------------------------------------
230
+
231
+VALID_SCOPES = {"read", "write", "admin"}
232
+
233
+
234
+@login_required
235
+def profile(request):
236
+ """Unified user profile page consolidating all personal settings."""
237
+ from fossil.notifications import NotificationPreference
238
+ from fossil.user_keys import UserSSHKey
239
+
240
+ user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
241
+ notif_prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
242
+ ssh_keys = UserSSHKey.objects.filter(user=request.user)
243
+ tokens = PersonalAccessToken.objects.filter(user=request.user, revoked_at__isnull=True)
244
+
245
+ return render(
246
+ request,
247
+ "accounts/profile.html",
248
+ {
249
+ "user_profile": user_profile,
250
+ "notif_prefs": notif_prefs,
251
+ "ssh_keys": ssh_keys,
252
+ "tokens": tokens,
253
+ },
254
+ )
255
+
256
+
257
+@login_required
258
+def profile_edit(request):
259
+ """Edit profile info: name, email, handle, bio, location, website."""
260
+ user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
261
+
262
+ if request.method == "POST":
263
+ user = request.user
264
+ user.first_name = request.POST.get("first_name", "").strip()[:30]
265
+ user.last_name = request.POST.get("last_name", "").strip()[:150]
266
+ user.email = request.POST.get("email", "").strip()[:254]
267
+ user.save(update_fields=["first_name", "last_name", "email"])
268
+
269
+ raw_handle = request.POST.get("handle", "").strip()
270
+ handle = UserProfile.sanitize_handle(raw_handle)
271
+ if handle:
272
+ # Check uniqueness (excluding self)
273
+ conflict = UserProfile.objects.filter(handle=handle).exclude(pk=user_profile.pk).exists()
274
+ if conflict:
275
+ messages.error(request, f"Handle @{handle} is already taken.")
276
+ return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
277
+ user_profile.handle = handle
278
+ else:
279
+ user_profile.handle = None
280
+
281
+ user_profile.bio = request.POST.get("bio", "").strip()[:500]
282
+ user_profile.location = request.POST.get("location", "").strip()[:100]
283
+ user_profile.website = request.POST.get("website", "").strip()[:200]
284
+ user_profile.save()
285
+
286
+ messages.success(request, "Profile updated.")
287
+ return redirect("accounts:profile")
288
+
289
+ return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
290
+
291
+
292
+@login_required
293
+def profile_token_create(request):
294
+ """Generate a personal access token. Shows the raw token once."""
295
+ if request.method == "POST":
296
+ name = request.POST.get("name", "").strip()
297
+ if not name:
298
+ messages.error(request, "Token name is required.")
299
+ return render(request, "accounts/profile_token_create.html", {})
300
+
301
+ raw_scopes = request.POST.get("scopes", "read").strip()
302
+ scopes = ",".join(s.strip() for s in raw_scopes.split(",") if s.strip() in VALID_SCOPES)
303
+ if not scopes:
304
+ scopes = "read"
305
+
306
+ raw_token, token_hash, prefix = PersonalAccessToken.generate()
307
+ PersonalAccessToken.objects.create(
308
+ user=request.user,
309
+ name=name,
310
+ token_hash=token_hash,
311
+ token_prefix=prefix,
312
+ scopes=scopes,
313
+ )
314
+
315
+ return render(
316
+ request,
317
+ "accounts/profile_token_created.html",
318
+ {"raw_token": raw_token, "token_name": name},
319
+ )
320
+
321
+ return render(request, "accounts/profile_token_create.html", {})
322
+
323
+
324
+@login_required
325
+@require_POST
326
+def profile_token_revoke(request, guid):
327
+ """Revoke a personal access token by GUID (token_prefix used as public id)."""
328
+ from django.utils import timezone
329
+
330
+ token = get_object_or_404(PersonalAccessToken, token_prefix=guid, user=request.user, revoked_at__isnull=True)
331
+ token.revoked_at = timezone.now()
332
+ token.save(update_fields=["revoked_at"])
333
+
334
+ messages.success(request, f'Token "{token.name}" revoked.')
335
+
336
+ if request.headers.get("HX-Request"):
337
+ return HttpResponse(status=200, headers={"HX-Redirect": "/auth/profile/"})
338
+
339
+ return redirect("accounts:profile")
224340
--- accounts/views.py
+++ accounts/views.py
@@ -8,10 +8,11 @@
8 from django.utils.http import url_has_allowed_host_and_scheme
9 from django.views.decorators.http import require_POST
10 from django_ratelimit.decorators import ratelimit
11
12 from .forms import LoginForm
 
13
14 # Allowed SSH key type prefixes
15 _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
16
17
@@ -219,5 +220,120 @@
219 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
220
221 return redirect("accounts:notification_prefs")
222
223 return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
--- accounts/views.py
+++ accounts/views.py
@@ -8,10 +8,11 @@
8 from django.utils.http import url_has_allowed_host_and_scheme
9 from django.views.decorators.http import require_POST
10 from django_ratelimit.decorators import ratelimit
11
12 from .forms import LoginForm
13 from .models import PersonalAccessToken, UserProfile
14
15 # Allowed SSH key type prefixes
16 _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
17
18
@@ -219,5 +220,120 @@
220 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
221
222 return redirect("accounts:notification_prefs")
223
224 return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
225
226
227 # ---------------------------------------------------------------------------
228 # Unified profile
229 # ---------------------------------------------------------------------------
230
231 VALID_SCOPES = {"read", "write", "admin"}
232
233
234 @login_required
235 def profile(request):
236 """Unified user profile page consolidating all personal settings."""
237 from fossil.notifications import NotificationPreference
238 from fossil.user_keys import UserSSHKey
239
240 user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
241 notif_prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
242 ssh_keys = UserSSHKey.objects.filter(user=request.user)
243 tokens = PersonalAccessToken.objects.filter(user=request.user, revoked_at__isnull=True)
244
245 return render(
246 request,
247 "accounts/profile.html",
248 {
249 "user_profile": user_profile,
250 "notif_prefs": notif_prefs,
251 "ssh_keys": ssh_keys,
252 "tokens": tokens,
253 },
254 )
255
256
257 @login_required
258 def profile_edit(request):
259 """Edit profile info: name, email, handle, bio, location, website."""
260 user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
261
262 if request.method == "POST":
263 user = request.user
264 user.first_name = request.POST.get("first_name", "").strip()[:30]
265 user.last_name = request.POST.get("last_name", "").strip()[:150]
266 user.email = request.POST.get("email", "").strip()[:254]
267 user.save(update_fields=["first_name", "last_name", "email"])
268
269 raw_handle = request.POST.get("handle", "").strip()
270 handle = UserProfile.sanitize_handle(raw_handle)
271 if handle:
272 # Check uniqueness (excluding self)
273 conflict = UserProfile.objects.filter(handle=handle).exclude(pk=user_profile.pk).exists()
274 if conflict:
275 messages.error(request, f"Handle @{handle} is already taken.")
276 return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
277 user_profile.handle = handle
278 else:
279 user_profile.handle = None
280
281 user_profile.bio = request.POST.get("bio", "").strip()[:500]
282 user_profile.location = request.POST.get("location", "").strip()[:100]
283 user_profile.website = request.POST.get("website", "").strip()[:200]
284 user_profile.save()
285
286 messages.success(request, "Profile updated.")
287 return redirect("accounts:profile")
288
289 return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
290
291
292 @login_required
293 def profile_token_create(request):
294 """Generate a personal access token. Shows the raw token once."""
295 if request.method == "POST":
296 name = request.POST.get("name", "").strip()
297 if not name:
298 messages.error(request, "Token name is required.")
299 return render(request, "accounts/profile_token_create.html", {})
300
301 raw_scopes = request.POST.get("scopes", "read").strip()
302 scopes = ",".join(s.strip() for s in raw_scopes.split(",") if s.strip() in VALID_SCOPES)
303 if not scopes:
304 scopes = "read"
305
306 raw_token, token_hash, prefix = PersonalAccessToken.generate()
307 PersonalAccessToken.objects.create(
308 user=request.user,
309 name=name,
310 token_hash=token_hash,
311 token_prefix=prefix,
312 scopes=scopes,
313 )
314
315 return render(
316 request,
317 "accounts/profile_token_created.html",
318 {"raw_token": raw_token, "token_name": name},
319 )
320
321 return render(request, "accounts/profile_token_create.html", {})
322
323
324 @login_required
325 @require_POST
326 def profile_token_revoke(request, guid):
327 """Revoke a personal access token by GUID (token_prefix used as public id)."""
328 from django.utils import timezone
329
330 token = get_object_or_404(PersonalAccessToken, token_prefix=guid, user=request.user, revoked_at__isnull=True)
331 token.revoked_at = timezone.now()
332 token.save(update_fields=["revoked_at"])
333
334 messages.success(request, f'Token "{token.name}" revoked.')
335
336 if request.headers.get("HX-Request"):
337 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/profile/"})
338
339 return redirect("accounts:profile")
340
--- config/urls.py
+++ config/urls.py
@@ -244,10 +244,11 @@
244244
return explore(request)
245245
246246
247247
urlpatterns = [
248248
path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
249
+ path("profile/", RedirectView.as_view(pattern_name="accounts:profile", permanent=False)),
249250
path("status/", status_page, name="status"),
250251
path("explore/", _explore_view, name="explore"),
251252
path("dashboard/", include("core.urls")),
252253
path("auth/", include("accounts.urls")),
253254
path("settings/", include("organization.urls")),
254255
--- config/urls.py
+++ config/urls.py
@@ -244,10 +244,11 @@
244 return explore(request)
245
246
247 urlpatterns = [
248 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
 
249 path("status/", status_page, name="status"),
250 path("explore/", _explore_view, name="explore"),
251 path("dashboard/", include("core.urls")),
252 path("auth/", include("accounts.urls")),
253 path("settings/", include("organization.urls")),
254
--- config/urls.py
+++ config/urls.py
@@ -244,10 +244,11 @@
244 return explore(request)
245
246
247 urlpatterns = [
248 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
249 path("profile/", RedirectView.as_view(pattern_name="accounts:profile", permanent=False)),
250 path("status/", status_page, name="status"),
251 path("explore/", _explore_view, name="explore"),
252 path("dashboard/", include("core.urls")),
253 path("auth/", include("accounts.urls")),
254 path("settings/", include("organization.urls")),
255
--- fossil/urls.py
+++ fossil/urls.py
@@ -55,10 +55,12 @@
5555
path("stats/", views.repo_stats, name="stats"),
5656
path("compare/", views.compare_checkins, name="compare"),
5757
path("settings/", views.repo_settings, name="repo_settings"),
5858
path("sync/", views.sync_pull, name="sync"),
5959
path("sync/git/", views.git_mirror_config, name="git_mirror"),
60
+ path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"),
61
+ path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"),
6062
path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
6163
path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
6264
path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
6365
path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
6466
path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
6567
--- fossil/urls.py
+++ fossil/urls.py
@@ -55,10 +55,12 @@
55 path("stats/", views.repo_stats, name="stats"),
56 path("compare/", views.compare_checkins, name="compare"),
57 path("settings/", views.repo_settings, name="repo_settings"),
58 path("sync/", views.sync_pull, name="sync"),
59 path("sync/git/", views.git_mirror_config, name="git_mirror"),
 
 
60 path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
61 path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
62 path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
63 path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
64 path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
65
--- fossil/urls.py
+++ fossil/urls.py
@@ -55,10 +55,12 @@
55 path("stats/", views.repo_stats, name="stats"),
56 path("compare/", views.compare_checkins, name="compare"),
57 path("settings/", views.repo_settings, name="repo_settings"),
58 path("sync/", views.sync_pull, name="sync"),
59 path("sync/git/", views.git_mirror_config, name="git_mirror"),
60 path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"),
61 path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"),
62 path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
63 path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
64 path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
65 path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
66 path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
67
+91 -32
--- fossil/views.py
+++ fossil/views.py
@@ -1442,19 +1442,24 @@
14421442
if result["artifacts_received"] > 0:
14431443
messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
14441444
else:
14451445
messages.info(request, "Already up to date.")
14461446
1447
+ from fossil.sync_models import GitMirror
1448
+
1449
+ mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
1450
+
14471451
return render(
14481452
request,
14491453
"fossil/sync.html",
14501454
{
14511455
"project": project,
14521456
"fossil_repo": fossil_repo,
14531457
"detected_remote": detected_remote,
14541458
"sync_configured": bool(fossil_repo.remote_url),
14551459
"result": result,
1460
+ "mirrors": mirrors,
14561461
"active_tab": "sync",
14571462
},
14581463
)
14591464
14601465
@@ -1567,21 +1572,29 @@
15671572
15681573
# --- Git Mirror ---
15691574
15701575
15711576
@login_required
1572
-def git_mirror_config(request, slug):
1573
- """Configure Git mirror sync for a project."""
1577
+def git_mirror_config(request, slug, mirror_id=None):
1578
+ """Configure Git mirror sync for a project.
1579
+
1580
+ If mirror_id is provided, edit that mirror. Otherwise show the add form.
1581
+ """
15741582
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
15751583
15761584
from fossil.sync_models import GitMirror
15771585
15781586
mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
15791587
1588
+ editing_mirror = None
1589
+ if mirror_id:
1590
+ editing_mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True)
1591
+
15801592
if request.method == "POST":
15811593
action = request.POST.get("action", "")
1582
- if action == "create":
1594
+
1595
+ if action in ("create", "update"):
15831596
git_url = request.POST.get("git_remote_url", "").strip()
15841597
auth_method = request.POST.get("auth_method", "token")
15851598
auth_credential = request.POST.get("auth_credential", "").strip()
15861599
# Use OAuth token from session if available and no manual credential provided
15871600
if not auth_credential:
@@ -1588,47 +1601,93 @@
15881601
if auth_method == "oauth_github" and request.session.get("github_oauth_token"):
15891602
auth_credential = request.session.pop("github_oauth_token")
15901603
elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"):
15911604
auth_credential = request.session.pop("gitlab_oauth_token")
15921605
sync_mode = request.POST.get("sync_mode", "scheduled")
1593
- sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip()
1594
- git_branch = request.POST.get("git_branch", "main").strip()
1595
-
1596
- if git_url:
1597
- GitMirror.objects.create(
1598
- repository=fossil_repo,
1599
- git_remote_url=git_url,
1600
- auth_method=auth_method,
1601
- auth_credential=auth_credential,
1602
- sync_mode=sync_mode,
1603
- sync_schedule=sync_schedule,
1604
- git_branch=git_branch,
1605
- created_by=request.user,
1606
- )
1607
- from django.contrib import messages
1608
-
1609
- messages.success(request, f"Git mirror configured: {git_url}")
1610
- from django.shortcuts import redirect
1611
-
1612
- return redirect("fossil:git_mirror", slug=slug)
1613
-
1614
- elif action == "delete":
1615
- mirror_id = request.POST.get("mirror_id")
1616
- mirror = GitMirror.objects.filter(pk=mirror_id, repository=fossil_repo).first()
1617
- if mirror:
1618
- mirror.soft_delete(user=request.user)
1619
- from django.contrib import messages
1620
-
1621
- messages.info(request, "Git mirror removed.")
1606
+ sync_direction = request.POST.get("sync_direction", "push")
1607
+ sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip()
1608
+ git_branch = request.POST.get("git_branch", "main").strip()
1609
+ fossil_branch = request.POST.get("fossil_branch", "trunk").strip()
1610
+ sync_tickets = request.POST.get("sync_tickets") == "on"
1611
+ sync_wiki = request.POST.get("sync_wiki") == "on"
1612
+
1613
+ if git_url:
1614
+ from django.contrib import messages
1615
+
1616
+ if action == "update" and editing_mirror:
1617
+ editing_mirror.git_remote_url = git_url
1618
+ editing_mirror.auth_method = auth_method
1619
+ if auth_credential: # Only update credential if a new one was provided
1620
+ editing_mirror.auth_credential = auth_credential
1621
+ editing_mirror.sync_mode = sync_mode
1622
+ editing_mirror.sync_direction = sync_direction
1623
+ editing_mirror.sync_schedule = sync_schedule
1624
+ editing_mirror.git_branch = git_branch
1625
+ editing_mirror.fossil_branch = fossil_branch
1626
+ editing_mirror.sync_tickets = sync_tickets
1627
+ editing_mirror.sync_wiki = sync_wiki
1628
+ editing_mirror.updated_by = request.user
1629
+ editing_mirror.save()
1630
+ messages.success(request, f"Mirror updated: {git_url}")
1631
+ else:
1632
+ GitMirror.objects.create(
1633
+ repository=fossil_repo,
1634
+ git_remote_url=git_url,
1635
+ auth_method=auth_method,
1636
+ auth_credential=auth_credential,
1637
+ sync_mode=sync_mode,
1638
+ sync_direction=sync_direction,
1639
+ sync_schedule=sync_schedule,
1640
+ git_branch=git_branch,
1641
+ fossil_branch=fossil_branch,
1642
+ sync_tickets=sync_tickets,
1643
+ sync_wiki=sync_wiki,
1644
+ created_by=request.user,
1645
+ )
1646
+ messages.success(request, f"Git mirror configured: {git_url}")
1647
+
1648
+ return redirect("fossil:git_mirror", slug=slug)
16221649
16231650
return render(
16241651
request,
16251652
"fossil/git_mirror.html",
16261653
{
16271654
"project": project,
16281655
"fossil_repo": fossil_repo,
16291656
"mirrors": mirrors,
1657
+ "editing_mirror": editing_mirror,
1658
+ "auth_method_choices": GitMirror.AuthMethod.choices,
1659
+ "sync_mode_choices": GitMirror.SyncMode.choices,
1660
+ "sync_direction_choices": GitMirror.SyncDirection.choices,
1661
+ "active_tab": "sync",
1662
+ },
1663
+ )
1664
+
1665
+
1666
+@login_required
1667
+def git_mirror_delete(request, slug, mirror_id):
1668
+ """Delete (soft-delete) a git mirror after confirmation."""
1669
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1670
+
1671
+ from fossil.sync_models import GitMirror
1672
+
1673
+ mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True)
1674
+
1675
+ if request.method == "POST":
1676
+ from django.contrib import messages
1677
+
1678
+ mirror.soft_delete(user=request.user)
1679
+ messages.success(request, f"Mirror to {mirror.git_remote_url} removed.")
1680
+ return redirect("fossil:sync", slug=slug)
1681
+
1682
+ return render(
1683
+ request,
1684
+ "fossil/git_mirror_delete.html",
1685
+ {
1686
+ "project": project,
1687
+ "fossil_repo": fossil_repo,
1688
+ "mirror": mirror,
16301689
"active_tab": "sync",
16311690
},
16321691
)
16331692
16341693
16351694
16361695
ADDED templates/accounts/profile.html
16371696
ADDED templates/accounts/profile_edit.html
16381697
ADDED templates/accounts/profile_token_create.html
16391698
ADDED templates/accounts/profile_token_created.html
--- fossil/views.py
+++ fossil/views.py
@@ -1442,19 +1442,24 @@
1442 if result["artifacts_received"] > 0:
1443 messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
1444 else:
1445 messages.info(request, "Already up to date.")
1446
 
 
 
 
1447 return render(
1448 request,
1449 "fossil/sync.html",
1450 {
1451 "project": project,
1452 "fossil_repo": fossil_repo,
1453 "detected_remote": detected_remote,
1454 "sync_configured": bool(fossil_repo.remote_url),
1455 "result": result,
 
1456 "active_tab": "sync",
1457 },
1458 )
1459
1460
@@ -1567,21 +1572,29 @@
1567
1568 # --- Git Mirror ---
1569
1570
1571 @login_required
1572 def git_mirror_config(request, slug):
1573 """Configure Git mirror sync for a project."""
 
 
 
1574 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1575
1576 from fossil.sync_models import GitMirror
1577
1578 mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
1579
 
 
 
 
1580 if request.method == "POST":
1581 action = request.POST.get("action", "")
1582 if action == "create":
 
1583 git_url = request.POST.get("git_remote_url", "").strip()
1584 auth_method = request.POST.get("auth_method", "token")
1585 auth_credential = request.POST.get("auth_credential", "").strip()
1586 # Use OAuth token from session if available and no manual credential provided
1587 if not auth_credential:
@@ -1588,47 +1601,93 @@
1588 if auth_method == "oauth_github" and request.session.get("github_oauth_token"):
1589 auth_credential = request.session.pop("github_oauth_token")
1590 elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"):
1591 auth_credential = request.session.pop("gitlab_oauth_token")
1592 sync_mode = request.POST.get("sync_mode", "scheduled")
1593 sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip()
1594 git_branch = request.POST.get("git_branch", "main").strip()
1595
1596 if git_url:
1597 GitMirror.objects.create(
1598 repository=fossil_repo,
1599 git_remote_url=git_url,
1600 auth_method=auth_method,
1601 auth_credential=auth_credential,
1602 sync_mode=sync_mode,
1603 sync_schedule=sync_schedule,
1604 git_branch=git_branch,
1605 created_by=request.user,
1606 )
1607 from django.contrib import messages
1608
1609 messages.success(request, f"Git mirror configured: {git_url}")
1610 from django.shortcuts import redirect
1611
1612 return redirect("fossil:git_mirror", slug=slug)
1613
1614 elif action == "delete":
1615 mirror_id = request.POST.get("mirror_id")
1616 mirror = GitMirror.objects.filter(pk=mirror_id, repository=fossil_repo).first()
1617 if mirror:
1618 mirror.soft_delete(user=request.user)
1619 from django.contrib import messages
1620
1621 messages.info(request, "Git mirror removed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1622
1623 return render(
1624 request,
1625 "fossil/git_mirror.html",
1626 {
1627 "project": project,
1628 "fossil_repo": fossil_repo,
1629 "mirrors": mirrors,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630 "active_tab": "sync",
1631 },
1632 )
1633
1634
1635
1636 DDED templates/accounts/profile.html
1637 DDED templates/accounts/profile_edit.html
1638 DDED templates/accounts/profile_token_create.html
1639 DDED templates/accounts/profile_token_created.html
--- fossil/views.py
+++ fossil/views.py
@@ -1442,19 +1442,24 @@
1442 if result["artifacts_received"] > 0:
1443 messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
1444 else:
1445 messages.info(request, "Already up to date.")
1446
1447 from fossil.sync_models import GitMirror
1448
1449 mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
1450
1451 return render(
1452 request,
1453 "fossil/sync.html",
1454 {
1455 "project": project,
1456 "fossil_repo": fossil_repo,
1457 "detected_remote": detected_remote,
1458 "sync_configured": bool(fossil_repo.remote_url),
1459 "result": result,
1460 "mirrors": mirrors,
1461 "active_tab": "sync",
1462 },
1463 )
1464
1465
@@ -1567,21 +1572,29 @@
1572
1573 # --- Git Mirror ---
1574
1575
1576 @login_required
1577 def git_mirror_config(request, slug, mirror_id=None):
1578 """Configure Git mirror sync for a project.
1579
1580 If mirror_id is provided, edit that mirror. Otherwise show the add form.
1581 """
1582 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1583
1584 from fossil.sync_models import GitMirror
1585
1586 mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
1587
1588 editing_mirror = None
1589 if mirror_id:
1590 editing_mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True)
1591
1592 if request.method == "POST":
1593 action = request.POST.get("action", "")
1594
1595 if action in ("create", "update"):
1596 git_url = request.POST.get("git_remote_url", "").strip()
1597 auth_method = request.POST.get("auth_method", "token")
1598 auth_credential = request.POST.get("auth_credential", "").strip()
1599 # Use OAuth token from session if available and no manual credential provided
1600 if not auth_credential:
@@ -1588,47 +1601,93 @@
1601 if auth_method == "oauth_github" and request.session.get("github_oauth_token"):
1602 auth_credential = request.session.pop("github_oauth_token")
1603 elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"):
1604 auth_credential = request.session.pop("gitlab_oauth_token")
1605 sync_mode = request.POST.get("sync_mode", "scheduled")
1606 sync_direction = request.POST.get("sync_direction", "push")
1607 sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip()
1608 git_branch = request.POST.get("git_branch", "main").strip()
1609 fossil_branch = request.POST.get("fossil_branch", "trunk").strip()
1610 sync_tickets = request.POST.get("sync_tickets") == "on"
1611 sync_wiki = request.POST.get("sync_wiki") == "on"
1612
1613 if git_url:
1614 from django.contrib import messages
1615
1616 if action == "update" and editing_mirror:
1617 editing_mirror.git_remote_url = git_url
1618 editing_mirror.auth_method = auth_method
1619 if auth_credential: # Only update credential if a new one was provided
1620 editing_mirror.auth_credential = auth_credential
1621 editing_mirror.sync_mode = sync_mode
1622 editing_mirror.sync_direction = sync_direction
1623 editing_mirror.sync_schedule = sync_schedule
1624 editing_mirror.git_branch = git_branch
1625 editing_mirror.fossil_branch = fossil_branch
1626 editing_mirror.sync_tickets = sync_tickets
1627 editing_mirror.sync_wiki = sync_wiki
1628 editing_mirror.updated_by = request.user
1629 editing_mirror.save()
1630 messages.success(request, f"Mirror updated: {git_url}")
1631 else:
1632 GitMirror.objects.create(
1633 repository=fossil_repo,
1634 git_remote_url=git_url,
1635 auth_method=auth_method,
1636 auth_credential=auth_credential,
1637 sync_mode=sync_mode,
1638 sync_direction=sync_direction,
1639 sync_schedule=sync_schedule,
1640 git_branch=git_branch,
1641 fossil_branch=fossil_branch,
1642 sync_tickets=sync_tickets,
1643 sync_wiki=sync_wiki,
1644 created_by=request.user,
1645 )
1646 messages.success(request, f"Git mirror configured: {git_url}")
1647
1648 return redirect("fossil:git_mirror", slug=slug)
1649
1650 return render(
1651 request,
1652 "fossil/git_mirror.html",
1653 {
1654 "project": project,
1655 "fossil_repo": fossil_repo,
1656 "mirrors": mirrors,
1657 "editing_mirror": editing_mirror,
1658 "auth_method_choices": GitMirror.AuthMethod.choices,
1659 "sync_mode_choices": GitMirror.SyncMode.choices,
1660 "sync_direction_choices": GitMirror.SyncDirection.choices,
1661 "active_tab": "sync",
1662 },
1663 )
1664
1665
1666 @login_required
1667 def git_mirror_delete(request, slug, mirror_id):
1668 """Delete (soft-delete) a git mirror after confirmation."""
1669 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1670
1671 from fossil.sync_models import GitMirror
1672
1673 mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True)
1674
1675 if request.method == "POST":
1676 from django.contrib import messages
1677
1678 mirror.soft_delete(user=request.user)
1679 messages.success(request, f"Mirror to {mirror.git_remote_url} removed.")
1680 return redirect("fossil:sync", slug=slug)
1681
1682 return render(
1683 request,
1684 "fossil/git_mirror_delete.html",
1685 {
1686 "project": project,
1687 "fossil_repo": fossil_repo,
1688 "mirror": mirror,
1689 "active_tab": "sync",
1690 },
1691 )
1692
1693
1694
1695 DDED templates/accounts/profile.html
1696 DDED templates/accounts/profile_edit.html
1697 DDED templates/accounts/profile_token_create.html
1698 DDED templates/accounts/profile_token_created.html
--- a/templates/accounts/profile.html
+++ b/templates/accounts/profile.html
@@ -0,0 +1,66 @@
1
+{% extends "base.html" %}
2
+{% block title %}Profile — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<!-- Profile Header -->
6
+<div class="text-sm font-mediugap-5 mb-8gray-800 px-4 py- min-w-0">
7
+ <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-centtext-2xl font-boltems-center gap-2 mt-4 sm-1 min-w-0">
8
+ <h1 class="text-2xl font-bold text-gray-100 truncate">
9
+ {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
10
+ </h1>
11
+text-sm font-mediugap-3 mt-1 text-0.5">{{ user.email|default
12
+ <span>@{{ user_prof {% endif %}
13
+ <span>{</div>
14
+ items-center gap-2"rname=user.username %}"accounts:profile_edit' %}"
15
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text">
16
+ Ed</a>
17
+ <a href="{% /div>
18
+</div>
19
+
20
+<!-- Profile %}"
21
+ class="rounded-md bg-gray-800 px-4 py-2 text-sm font-medium teEdit Profile
22
+ </a gap-x-6 <prefs.notify_tickets %}{% if nt>
23
+ <dd class="text-gray-200 mt-0.5">{{ user.get_full_name|default:"Not set"andle</dt>
24
+ <dd class="text-gray-200 mt-0.5">{% if user_profile.handle %}@{{ user_profile.handle }}{% else %}Not set{% endif %}</dd>
25
+ </div>
26
+ <div>
27
+ <dt class="text-gray-500">Email</dt>
28
+ <dd class="text-gray-200 mt-0.5">{{ user.email|default:"Not set" }}</dd>
29
+ </div>
30
+ <div>
31
+ <dt class="text-gray-500">Location</dt>
32
+ <dd class="text-gray-200 mt-0.5">{{ user_profile.location|default:"Not set" }}</dd>
33
+ </div>
34
+ <div>
35
+ <dt class="text-gray-500">Website</dt>
36
+ <dd class="text-gray-200 mt-0.5">
37
+ {% if user_profile.website %}
38
+ <a href="{{ user_profile.website }}" class="text-brand-light hover:text-brand" target="_blank" rel="noopener">{{ user_profile.website }}</a>
39
+ {% else %}Not set{% endif %}
40
+ </dd>
41
+ </div>
42
+ <div class="sm:col-span-2">
43
+ <dt class="text-gray-500">Bio</dt>
44
+ <dd class="text-gray-200 mt-0.5">{{ user_profile.bio|default:"Not set"|linebreaksbr }}</dd>
45
+ </div>
46
+ </dl>
47
+</div>
48
+
49
+<!-- SSH Keys Card -->
50
+<div class="rounded-lg bg-gray-800 ">
51
+ <div class="p-4 border-b border-gray-700 flex items-center justify-bet <prefs.notify_tickets %}{% if nt>
52
+ <dd class="text-gray-200
53
+ {% if notif_prefs.notify_checkins %}Checkins{% endif %}
54
+ tif_prefs.notify_tickets %}{% if notif_prefs.notify_checkins %}, {% endif %}Ticket endif %}Tickets{% endif %}
55
+ {% if notif_prefs.notify_wiki %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets %}, {% endif %}Wiki{% endif %}
56
+ {% if notif_prefs.notify_releases %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki %}, {% endif %}Releases{% endif %}
57
+ {% if notif_prefs.notify_forum %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki or notif_prefs.notify_releases %}, {% endif %}Forum{% endif %}
58
+ {% if not notif_prefs.notify_checkins and not notif_prefs.notify_tickets and not notif_prefs.notify_wiki and not notif_prefs.notify_releases and not notif_prefs.notify_forum %}None{% endif %}
59
+ </dd>
60
+ </div>
61
+ </dl>
62
+ </div>
63
+</div>
64
+
65
+<!-- Personal Access Tokens Card -->
66
+<div class="rounded-lg bg-gray-800
--- a/templates/accounts/profile.html
+++ b/templates/accounts/profile.html
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/accounts/profile.html
+++ b/templates/accounts/profile.html
@@ -0,0 +1,66 @@
1 {% extends "base.html" %}
2 {% block title %}Profile — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <!-- Profile Header -->
6 <div class="text-sm font-mediugap-5 mb-8gray-800 px-4 py- min-w-0">
7 <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-centtext-2xl font-boltems-center gap-2 mt-4 sm-1 min-w-0">
8 <h1 class="text-2xl font-bold text-gray-100 truncate">
9 {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
10 </h1>
11 text-sm font-mediugap-3 mt-1 text-0.5">{{ user.email|default
12 <span>@{{ user_prof {% endif %}
13 <span>{</div>
14 items-center gap-2"rname=user.username %}"accounts:profile_edit' %}"
15 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text">
16 Ed</a>
17 <a href="{% /div>
18 </div>
19
20 <!-- Profile %}"
21 class="rounded-md bg-gray-800 px-4 py-2 text-sm font-medium teEdit Profile
22 </a gap-x-6 <prefs.notify_tickets %}{% if nt>
23 <dd class="text-gray-200 mt-0.5">{{ user.get_full_name|default:"Not set"andle</dt>
24 <dd class="text-gray-200 mt-0.5">{% if user_profile.handle %}@{{ user_profile.handle }}{% else %}Not set{% endif %}</dd>
25 </div>
26 <div>
27 <dt class="text-gray-500">Email</dt>
28 <dd class="text-gray-200 mt-0.5">{{ user.email|default:"Not set" }}</dd>
29 </div>
30 <div>
31 <dt class="text-gray-500">Location</dt>
32 <dd class="text-gray-200 mt-0.5">{{ user_profile.location|default:"Not set" }}</dd>
33 </div>
34 <div>
35 <dt class="text-gray-500">Website</dt>
36 <dd class="text-gray-200 mt-0.5">
37 {% if user_profile.website %}
38 <a href="{{ user_profile.website }}" class="text-brand-light hover:text-brand" target="_blank" rel="noopener">{{ user_profile.website }}</a>
39 {% else %}Not set{% endif %}
40 </dd>
41 </div>
42 <div class="sm:col-span-2">
43 <dt class="text-gray-500">Bio</dt>
44 <dd class="text-gray-200 mt-0.5">{{ user_profile.bio|default:"Not set"|linebreaksbr }}</dd>
45 </div>
46 </dl>
47 </div>
48
49 <!-- SSH Keys Card -->
50 <div class="rounded-lg bg-gray-800 ">
51 <div class="p-4 border-b border-gray-700 flex items-center justify-bet <prefs.notify_tickets %}{% if nt>
52 <dd class="text-gray-200
53 {% if notif_prefs.notify_checkins %}Checkins{% endif %}
54 tif_prefs.notify_tickets %}{% if notif_prefs.notify_checkins %}, {% endif %}Ticket endif %}Tickets{% endif %}
55 {% if notif_prefs.notify_wiki %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets %}, {% endif %}Wiki{% endif %}
56 {% if notif_prefs.notify_releases %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki %}, {% endif %}Releases{% endif %}
57 {% if notif_prefs.notify_forum %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki or notif_prefs.notify_releases %}, {% endif %}Forum{% endif %}
58 {% if not notif_prefs.notify_checkins and not notif_prefs.notify_tickets and not notif_prefs.notify_wiki and not notif_prefs.notify_releases and not notif_prefs.notify_forum %}None{% endif %}
59 </dd>
60 </div>
61 </dl>
62 </div>
63 </div>
64
65 <!-- Personal Access Tokens Card -->
66 <div class="rounded-lg bg-gray-800
--- a/templates/accounts/profile_edit.html
+++ b/templates/accounts/profile_edit.html
@@ -0,0 +1,48 @@
1
+{% extends "base.html" %}
2
+{% block title %}Edit Profile — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="max-w-2xl">
6
+ <div class="flex items-center gap-3 mb-6">
7
+ <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white">
8
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
10
+ </svg>
11
+ </a>
12
+ <h1 class="text-2xl font-bold text-gray-100">Edit Profile</h1>
13
+ </div>
14
+
15
+ <form method="post" class="space-y-6">
16
+ {% csrf_token %}
17
+
18
+ <div class="rounded-lg bg-gray-800 border bo">st_name" id="last_name" value="{{ user.last_name }}" maxlength="150"
19
+ class="w-div>
20
+ <label for="first_name" class="block text-sm font-medium text-gray-300 mb-1">First Name</label>
21
+ <input type="text" name="first_name" id="first_name" value="{{ user.first_name }}" maxlength="30"
22
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
23
+ </div>
24
+ <div>
25
+ <label for="last_name" class="block text-sm font-medium text-gray-300 mb-1">Last Name</label>
26
+ <input type="text" name="last_name" id="last_name" value="{{ user.last_name }}" maxlength="150"
27
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
28
+ </div>
29
+ </div>
30
+
31
+ <div>
32
+ <label for="email" class="block text-sm font-medium text-gray-300 mb-1">Email</label>
33
+ <input type="email" name="email" id="email" value="{{ user.email }}" maxlength="254"
34
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
35
+ </div>
36
+
37
+ <div>
38
+ <label for="handle" class="block text-sm font-medium text-gray-300 mb-1">Handle</label>
39
+ <div class="flex items-center">
40
+ <span class="text-gray-500 text-sm mr-1">@</span>
41
+ <input type="text" name="handle" id="handle" value="{{ user_profile.handle }}" maxlength="50"
42
+ pattern="[a-z0-9\-]+" title="Lowercase letters, numbers, and hyphens only"
43
+ place">
44
+ Save Changes
45
+ </button>
46
+ <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a>
47
+ </div>
48
+ </f
--- a/templates/accounts/profile_edit.html
+++ b/templates/accounts/profile_edit.html
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/accounts/profile_edit.html
+++ b/templates/accounts/profile_edit.html
@@ -0,0 +1,48 @@
1 {% extends "base.html" %}
2 {% block title %}Edit Profile — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="max-w-2xl">
6 <div class="flex items-center gap-3 mb-6">
7 <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white">
8 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9 <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
10 </svg>
11 </a>
12 <h1 class="text-2xl font-bold text-gray-100">Edit Profile</h1>
13 </div>
14
15 <form method="post" class="space-y-6">
16 {% csrf_token %}
17
18 <div class="rounded-lg bg-gray-800 border bo">st_name" id="last_name" value="{{ user.last_name }}" maxlength="150"
19 class="w-div>
20 <label for="first_name" class="block text-sm font-medium text-gray-300 mb-1">First Name</label>
21 <input type="text" name="first_name" id="first_name" value="{{ user.first_name }}" maxlength="30"
22 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
23 </div>
24 <div>
25 <label for="last_name" class="block text-sm font-medium text-gray-300 mb-1">Last Name</label>
26 <input type="text" name="last_name" id="last_name" value="{{ user.last_name }}" maxlength="150"
27 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
28 </div>
29 </div>
30
31 <div>
32 <label for="email" class="block text-sm font-medium text-gray-300 mb-1">Email</label>
33 <input type="email" name="email" id="email" value="{{ user.email }}" maxlength="254"
34 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
35 </div>
36
37 <div>
38 <label for="handle" class="block text-sm font-medium text-gray-300 mb-1">Handle</label>
39 <div class="flex items-center">
40 <span class="text-gray-500 text-sm mr-1">@</span>
41 <input type="text" name="handle" id="handle" value="{{ user_profile.handle }}" maxlength="50"
42 pattern="[a-z0-9\-]+" title="Lowercase letters, numbers, and hyphens only"
43 place">
44 Save Changes
45 </button>
46 <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a>
47 </div>
48 </f
--- a/templates/accounts/profile_token_create.html
+++ b/templates/accounts/profile_token_create.html
@@ -0,0 +1,76 @@
1
+{% extends "base.html" %}
2
+{% block title %}Generate Token — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="max-w-2xl">
6
+ <div class="flex items-center gap-3 mb-6">
7
+ <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white">
8
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
10
+ </svg>
11
+ </a>
12
+ <h1 class="text-2xl font-bold text-gray-100">Generate Personal Access Token</h1>
13
+ </div>
14
+
15
+ <form method="post" class="space-y-6">
16
+ {% csrf_token %}
17
+
18
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 space-y-4">
19
+ <div>
20
+ <label for="name" class="block text-sm font-medium text-gray-300 mb-1">Token Name</label>
21
+ <input type="text" name="name" id="name" required maxlength="200"
22
+ placeholder="e.g. CI/CD pipeline, CLI access"
23
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
24
+ <p class="mt-1 text-xs text-gray-500">A descriptive name to remember what this token is for.</p>
25
+ </div>
26
+
27
+ <div>
28
+ <label class="block text-sm font-medium text-gray-300 mb-2">Scopes</label>
29
+ <div class="space-y-2">
30
+ <label class="flex items-center gap-3 cursor-pointer">
31
+ <input type="checkbox" name="scope_read" checked disabled
32
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
33
+ <div>
34
+ <span class="text-sm font-medium text-gray-200">read</span>
35
+ <span class="text-xs text-gray-500 ml-2">Read access to repositories and data (always included)</span>
36
+ </div>
37
+ </label>
38
+ <label class="flex items-center gap-3 cursor-pointer">
39
+ <input type="checkbox" name="scope_write" value="write"
40
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
41
+ <div>
42
+ <span class="text-sm font-medium text-gray-200">write</span>
43
+ <span class="text-xs text-gray-500 ml-2">Push changes, create/update tickets and wiki</span>
44
+ </div>
45
+ </label>
46
+ <label class="flex items-center gap-3 cursor-pointer">
47
+ <input type="checkbox" name="scope_admin" value="admin"
48
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
49
+ <div>
50
+ <span class="text-sm font-medium text-gray-200">admin</span>
51
+ <span class="text-xs text-gray-500 ml-2">Full administrative access</span>
52
+ </div>
53
+ </label>
54
+ </div>
55
+ <input type="hidden" name="scopes" id="scopes_hidden" value="read">
56
+ </div>
57
+ </div>
58
+
59
+ <div class="flex items-center gap-3">
60
+ <button type="submit" onclick="buildScopes()" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">
61
+ Generate Token
62
+ </button>
63
+ <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a>
64
+ </div>
65
+ </form>
66
+</div>
67
+
68
+<script>
69
+function buildScopes() {
70
+ var scopes = ['read'];
71
+ if (document.querySelector('input[name="scope_write"]').checked) scopes.push('write');
72
+ if (document.querySelector('input[name="scope_admin"]').checked) scopes.push('admin');
73
+ document.getElementById('scopes_hidden').value = scopes.join(',');
74
+}
75
+</script>
76
+{% endblock %}
--- a/templates/accounts/profile_token_create.html
+++ b/templates/accounts/profile_token_create.html
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/accounts/profile_token_create.html
+++ b/templates/accounts/profile_token_create.html
@@ -0,0 +1,76 @@
1 {% extends "base.html" %}
2 {% block title %}Generate Token — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="max-w-2xl">
6 <div class="flex items-center gap-3 mb-6">
7 <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white">
8 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9 <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
10 </svg>
11 </a>
12 <h1 class="text-2xl font-bold text-gray-100">Generate Personal Access Token</h1>
13 </div>
14
15 <form method="post" class="space-y-6">
16 {% csrf_token %}
17
18 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 space-y-4">
19 <div>
20 <label for="name" class="block text-sm font-medium text-gray-300 mb-1">Token Name</label>
21 <input type="text" name="name" id="name" required maxlength="200"
22 placeholder="e.g. CI/CD pipeline, CLI access"
23 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
24 <p class="mt-1 text-xs text-gray-500">A descriptive name to remember what this token is for.</p>
25 </div>
26
27 <div>
28 <label class="block text-sm font-medium text-gray-300 mb-2">Scopes</label>
29 <div class="space-y-2">
30 <label class="flex items-center gap-3 cursor-pointer">
31 <input type="checkbox" name="scope_read" checked disabled
32 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
33 <div>
34 <span class="text-sm font-medium text-gray-200">read</span>
35 <span class="text-xs text-gray-500 ml-2">Read access to repositories and data (always included)</span>
36 </div>
37 </label>
38 <label class="flex items-center gap-3 cursor-pointer">
39 <input type="checkbox" name="scope_write" value="write"
40 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
41 <div>
42 <span class="text-sm font-medium text-gray-200">write</span>
43 <span class="text-xs text-gray-500 ml-2">Push changes, create/update tickets and wiki</span>
44 </div>
45 </label>
46 <label class="flex items-center gap-3 cursor-pointer">
47 <input type="checkbox" name="scope_admin" value="admin"
48 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
49 <div>
50 <span class="text-sm font-medium text-gray-200">admin</span>
51 <span class="text-xs text-gray-500 ml-2">Full administrative access</span>
52 </div>
53 </label>
54 </div>
55 <input type="hidden" name="scopes" id="scopes_hidden" value="read">
56 </div>
57 </div>
58
59 <div class="flex items-center gap-3">
60 <button type="submit" onclick="buildScopes()" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">
61 Generate Token
62 </button>
63 <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a>
64 </div>
65 </form>
66 </div>
67
68 <script>
69 function buildScopes() {
70 var scopes = ['read'];
71 if (document.querySelector('input[name="scope_write"]').checked) scopes.push('write');
72 if (document.querySelector('input[name="scope_admin"]').checked) scopes.push('admin');
73 document.getElementById('scopes_hidden').value = scopes.join(',');
74 }
75 </script>
76 {% endblock %}
--- a/templates/accounts/profile_token_created.html
+++ b/templates/accounts/profile_token_created.html
@@ -0,0 +1,44 @@
1
+{% extends "base.html" %}
2
+{% block title %}Token Generated — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="max-w-2xl">
6
+ <div class="flex items-center gap-3 mb-6">
7
+ <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white">
8
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
10
+ </svg>
11
+ </a>
12
+ <h1 class="text-2xl font-bold text-gray-100">Token Generated</h1>
13
+ </div>
14
+
15
+ <div class="rounded-lg bg-yellow-900/50 border border-yellow-700 p-4 mb-6">
16
+ <div class="flex items-start gap-3">
17
+ <svg class="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
18
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
19
+ </svg>
20
+ <div>
21
+ <p class="text-sm font-medium text-yellow-300">Copy this token now. It will not be shown again.</p>
22
+ <p class="text-xs text-yellow-400 mt-1">Store it securely. If lost, revoke it and generate a new one.</p>
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
28
+ <label class="block text-sm font-medium text-gray-300 mb-2">{{ token_name }}</label>
29
+ <div class="flex items-center gap-2" x-data="{ copied: false }">
30
+ <code class="flex-1 block rounded-md bg-gray-900 border border-gray-600 px-3 py-2 text-sm font-mono text-gray-100 break-all select-all">{{ raw_token }}</code>
31
+ <button type="button"
32
+ @click="navigator.clipboard.writeText('{{ raw_token }}'); copied = true; setTimeout(() => copied = false, 2000)"
33
+ class="flex-shrink-0 rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-200 hover:bg-gray-600">
34
+ <span x-show="!copied">Copy</span>
35
+ <span x-show="copied" style="display:none">Copied</span>
36
+ </button>
37
+ </div>
38
+ </div>
39
+
40
+ <a href="{% url 'accounts:profile' %}" class="inline-block rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600">
41
+ Back to Profile
42
+ </a>
43
+</div>
44
+{% endblock %}
--- a/templates/accounts/profile_token_created.html
+++ b/templates/accounts/profile_token_created.html
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/accounts/profile_token_created.html
+++ b/templates/accounts/profile_token_created.html
@@ -0,0 +1,44 @@
1 {% extends "base.html" %}
2 {% block title %}Token Generated — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="max-w-2xl">
6 <div class="flex items-center gap-3 mb-6">
7 <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white">
8 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9 <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
10 </svg>
11 </a>
12 <h1 class="text-2xl font-bold text-gray-100">Token Generated</h1>
13 </div>
14
15 <div class="rounded-lg bg-yellow-900/50 border border-yellow-700 p-4 mb-6">
16 <div class="flex items-start gap-3">
17 <svg class="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
18 <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
19 </svg>
20 <div>
21 <p class="text-sm font-medium text-yellow-300">Copy this token now. It will not be shown again.</p>
22 <p class="text-xs text-yellow-400 mt-1">Store it securely. If lost, revoke it and generate a new one.</p>
23 </div>
24 </div>
25 </div>
26
27 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
28 <label class="block text-sm font-medium text-gray-300 mb-2">{{ token_name }}</label>
29 <div class="flex items-center gap-2" x-data="{ copied: false }">
30 <code class="flex-1 block rounded-md bg-gray-900 border border-gray-600 px-3 py-2 text-sm font-mono text-gray-100 break-all select-all">{{ raw_token }}</code>
31 <button type="button"
32 @click="navigator.clipboard.writeText('{{ raw_token }}'); copied = true; setTimeout(() => copied = false, 2000)"
33 class="flex-shrink-0 rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-200 hover:bg-gray-600">
34 <span x-show="!copied">Copy</span>
35 <span x-show="copied" style="display:none">Copied</span>
36 </button>
37 </div>
38 </div>
39
40 <a href="{% url 'accounts:profile' %}" class="inline-block rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600">
41 Back to Profile
42 </a>
43 </div>
44 {% endblock %}
--- templates/fossil/git_mirror.html
+++ templates/fossil/git_mirror.html
@@ -1,66 +1,20 @@
11
{% extends "base.html" %}
2
-{% block title %}Git Mirror — {{ project.name }} — Fossilrepo{% endblock %}
2
+{% block title %}{% if editing_mirror %}Edit Mirror{% else %}Add Git Mirror{% endif %} — {{ project.name }} — Fossilrepo{% endblock %}
33
44
{% block content %}
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
77
88
<div class="max-w-3xl">
99
<div class="flex items-center justify-between mb-6">
10
- <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2>
11
- <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Fossil Sync</a>
12
- </div>
13
-
14
- <!-- Existing mirrors -->
15
- {% if mirrors %}
16
- <div class="space-y-4 mb-8">
17
- {% for mirror in mirrors %}
18
- <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
19
- <div class="flex items-start justify-between">
20
- <div>
21
- <div class="font-mono text-sm text-gray-200">{{ mirror.git_remote_url }}</div>
22
- <div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
23
- <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span>
24
- <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_mode_display }}</span>
25
- <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_direction_display }}</span>
26
- <span>→ {{ mirror.git_branch }}</span>
27
- </div>
28
- <div class="mt-2 text-xs text-gray-500">
29
- {% if mirror.last_sync_at %}
30
- Last sync: {{ mirror.last_sync_at|timesince }} ago
31
- <span class="{% if mirror.last_sync_status == 'success' %}text-green-400{% else %}text-red-400{% endif %}">
32
- ({{ mirror.last_sync_status }})
33
- </span>
34
- — {{ mirror.total_syncs }} total syncs
35
- {% else %}
36
- Never synced
37
- {% endif %}
38
- </div>
39
- {% if mirror.last_sync_message %}
40
- <pre class="mt-1 text-xs text-gray-600 max-h-20 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre>
41
- {% endif %}
42
- </div>
43
- <div class="flex items-center gap-2">
44
- <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}">
45
- {% csrf_token %}
46
- <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover">Sync Now</button>
47
- </form>
48
- <form method="post">
49
- {% csrf_token %}
50
- <input type="hidden" name="action" value="delete">
51
- <input type="hidden" name="mirror_id" value="{{ mirror.id }}">
52
- <button type="submit" class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600">Remove</button>
53
- </form>
54
- </div>
55
- </div>
56
- </div>
57
- {% endfor %}
58
- </div>
59
- {% endif %}
60
-
61
- <!-- OAuth connect buttons -->
10
+ <h2 class="text-lg font-semibold text-gray-200">{% if editing_mirror %}Edit Git Mirror{% else %}Add Git Mirror{% endif %}</h2>
11
+ <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Sync</a>
12
+ </div>
13
+
14
+ {# ── OAuth quick-connect (only shown when adding) ──────────────── #}
15
+ {% if not editing_mirror %}
6216
<div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6">
6317
<h3 class="text-sm font-semibold text-gray-200 mb-3 text-center">Quick Connect</h3>
6418
<div class="flex items-center justify-center gap-3">
6519
<a href="{% url 'fossil:oauth_github' slug=project.slug %}"
6620
class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-gray-200 ring-1 ring-inset ring-gray-600 hover:bg-gray-700">
@@ -72,76 +26,119 @@
7226
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg>
7327
Connect GitLab
7428
</a>
7529
</div>
7630
{% if request.session.github_oauth_token %}
77
- <p class="mt-2 text-xs text-green-400">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p>
31
+ <p class="mt-2 text-xs text-green-400 text-center">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p>
7832
{% endif %}
7933
{% if request.session.gitlab_oauth_token %}
80
- <p class="mt-2 text-xs text-green-400">GitLab connected. Token will be used for new mirrors.</p>
34
+ <p class="mt-2 text-xs text-green-400 text-center">GitLab connected. Token will be used for new mirrors.</p>
8135
{% endif %}
8236
</div>
37
+ {% endif %}
8338
84
- <!-- Add new mirror -->
39
+ {# ── Mirror form (add or edit) ─────────────────────────────────── #}
8540
<div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
86
- <h3 class="text-base font-semibold text-gray-200 mb-4">Add Git Mirror</h3>
87
-
8841
<form method="post" class="space-y-4">
8942
{% csrf_token %}
90
- <input type="hidden" name="action" value="create">
43
+ <input type="hidden" name="action" value="{% if editing_mirror %}update{% else %}create{% endif %}">
9144
9245
<div>
9346
<label class="block text-sm font-medium text-gray-300 mb-1">Git Remote URL <span class="text-red-400">*</span></label>
94
- <input type="text" name="git_remote_url" required placeholder="https://github.com/org/repo.git"
47
+ <input type="text" name="git_remote_url" required
48
+ value="{{ editing_mirror.git_remote_url|default:'' }}"
49
+ placeholder="https://github.com/org/repo.git"
9550
class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
9651
</div>
9752
9853
<div class="grid grid-cols-2 gap-4">
9954
<div>
100
- <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label>
101
- <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
102
- <option value="token">Personal Access Token</option>
103
- <option value="ssh">SSH Key</option>
104
- <option value="oauth_github">GitHub OAuth</option>
105
- <option value="oauth_gitlab">GitLab OAuth</option>
55
+ <label class="block text-sm font-medium text-gray-300 mb-1">Sync Direction</label>
56
+ <select name="sync_direction" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
57
+ {% for value, label in sync_direction_choices %}
58
+ <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_direction == value %}selected{% endif %}>{{ label }}</option>
59
+ {% endfor %}
10660
</select>
10761
</div>
10862
<div>
109
- <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label>
110
- <input type="text" name="git_branch" value="main"
111
- class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2">
63
+ <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label>
64
+ <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
65
+ {% for value, label in auth_method_choices %}
66
+ <option value="{{ value }}" {% if editing_mirror and editing_mirror.auth_method == value %}selected{% endif %}>{{ label }}</option>
67
+ {% endfor %}
68
+ </select>
11269
</div>
11370
</div>
11471
11572
<div>
11673
<label class="block text-sm font-medium text-gray-300 mb-1">Token / Credential</label>
117
- <input type="password" name="auth_credential" placeholder="ghp_xxxxxxxxxxxx"
74
+ <input type="password" name="auth_credential"
75
+ placeholder="{% if editing_mirror %}Leave blank to keep current credential{% else %}ghp_xxxxxxxxxxxx{% endif %}"
11876
class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
119
- <p class="mt-1 text-xs text-gray-500">For token auth: GitHub Personal Access Token or GitLab Access Token</p>
77
+ <p class="mt-1 text-xs text-gray-500">
78
+ {% if editing_mirror %}Leave blank to keep the existing credential.{% else %}For token auth: GitHub Personal Access Token or GitLab Access Token.{% endif %}
79
+ </p>
80
+ </div>
81
+
82
+ <div class="grid grid-cols-2 gap-4">
83
+ <div>
84
+ <label class="block text-sm font-medium text-gray-300 mb-1">Fossil Branch</label>
85
+ <input type="text" name="fossil_branch"
86
+ value="{{ editing_mirror.fossil_branch|default:'trunk' }}"
87
+ class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2">
88
+ </div>
89
+ <div>
90
+ <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label>
91
+ <input type="text" name="git_branch"
92
+ value="{{ editing_mirror.git_branch|default:'main' }}"
93
+ class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2">
94
+ </div>
12095
</div>
12196
12297
<div class="grid grid-cols-2 gap-4">
12398
<div>
12499
<label class="block text-sm font-medium text-gray-300 mb-1">Sync Mode</label>
125100
<select name="sync_mode" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
126
- <option value="scheduled">Scheduled</option>
127
- <option value="on_change">On Change</option>
128
- <option value="both">Both</option>
101
+ {% for value, label in sync_mode_choices %}
102
+ <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_mode == value %}selected{% endif %}>{{ label }}</option>
103
+ {% endfor %}
129104
</select>
130105
</div>
131106
<div>
132107
<label class="block text-sm font-medium text-gray-300 mb-1">Schedule (cron)</label>
133
- <input type="text" name="sync_schedule" value="*/15 * * * *"
108
+ <input type="text" name="sync_schedule"
109
+ value="{{ editing_mirror.sync_schedule|default:'*/15 * * * *' }}"
134110
class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
135111
<p class="mt-1 text-xs text-gray-500">Default: every 15 minutes</p>
136112
</div>
137113
</div>
138114
139
- <div class="flex justify-end pt-2">
115
+ {# Sync content options #}
116
+ <div>
117
+ <span class="block text-sm font-medium text-gray-300 mb-2">Sync Content</span>
118
+ <div class="flex items-center gap-6">
119
+ <label class="flex items-center gap-2 text-sm text-gray-300">
120
+ <input type="checkbox" name="sync_tickets"
121
+ {% if editing_mirror and editing_mirror.sync_tickets %}checked{% endif %}
122
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
123
+ Tickets as Issues
124
+ </label>
125
+ <label class="flex items-center gap-2 text-sm text-gray-300">
126
+ <input type="checkbox" name="sync_wiki"
127
+ {% if editing_mirror and editing_mirror.sync_wiki %}checked{% endif %}
128
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
129
+ Wiki pages
130
+ </label>
131
+ </div>
132
+ <p class="mt-1 text-xs text-gray-500">Code is always synced. Optionally sync tickets and wiki.</p>
133
+ </div>
134
+
135
+ <div class="flex items-center justify-between pt-2">
136
+ <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-gray-400 hover:text-gray-200">Cancel</a>
140137
<button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
141
- Add Mirror
138
+ {% if editing_mirror %}Update Mirror{% else %}Add Mirror{% endif %}
142139
</button>
143140
</div>
144141
</form>
145142
</div>
146143
</div>
147144
{% endblock %}
148145
149146
ADDED templates/fossil/git_mirror_delete.html
--- templates/fossil/git_mirror.html
+++ templates/fossil/git_mirror.html
@@ -1,66 +1,20 @@
1 {% extends "base.html" %}
2 {% block title %}Git Mirror — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="max-w-3xl">
9 <div class="flex items-center justify-between mb-6">
10 <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2>
11 <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Fossil Sync</a>
12 </div>
13
14 <!-- Existing mirrors -->
15 {% if mirrors %}
16 <div class="space-y-4 mb-8">
17 {% for mirror in mirrors %}
18 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
19 <div class="flex items-start justify-between">
20 <div>
21 <div class="font-mono text-sm text-gray-200">{{ mirror.git_remote_url }}</div>
22 <div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
23 <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span>
24 <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_mode_display }}</span>
25 <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_direction_display }}</span>
26 <span>→ {{ mirror.git_branch }}</span>
27 </div>
28 <div class="mt-2 text-xs text-gray-500">
29 {% if mirror.last_sync_at %}
30 Last sync: {{ mirror.last_sync_at|timesince }} ago
31 <span class="{% if mirror.last_sync_status == 'success' %}text-green-400{% else %}text-red-400{% endif %}">
32 ({{ mirror.last_sync_status }})
33 </span>
34 — {{ mirror.total_syncs }} total syncs
35 {% else %}
36 Never synced
37 {% endif %}
38 </div>
39 {% if mirror.last_sync_message %}
40 <pre class="mt-1 text-xs text-gray-600 max-h-20 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre>
41 {% endif %}
42 </div>
43 <div class="flex items-center gap-2">
44 <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}">
45 {% csrf_token %}
46 <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover">Sync Now</button>
47 </form>
48 <form method="post">
49 {% csrf_token %}
50 <input type="hidden" name="action" value="delete">
51 <input type="hidden" name="mirror_id" value="{{ mirror.id }}">
52 <button type="submit" class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600">Remove</button>
53 </form>
54 </div>
55 </div>
56 </div>
57 {% endfor %}
58 </div>
59 {% endif %}
60
61 <!-- OAuth connect buttons -->
62 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6">
63 <h3 class="text-sm font-semibold text-gray-200 mb-3 text-center">Quick Connect</h3>
64 <div class="flex items-center justify-center gap-3">
65 <a href="{% url 'fossil:oauth_github' slug=project.slug %}"
66 class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-gray-200 ring-1 ring-inset ring-gray-600 hover:bg-gray-700">
@@ -72,76 +26,119 @@
72 <svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg>
73 Connect GitLab
74 </a>
75 </div>
76 {% if request.session.github_oauth_token %}
77 <p class="mt-2 text-xs text-green-400">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p>
78 {% endif %}
79 {% if request.session.gitlab_oauth_token %}
80 <p class="mt-2 text-xs text-green-400">GitLab connected. Token will be used for new mirrors.</p>
81 {% endif %}
82 </div>
 
83
84 <!-- Add new mirror -->
85 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
86 <h3 class="text-base font-semibold text-gray-200 mb-4">Add Git Mirror</h3>
87
88 <form method="post" class="space-y-4">
89 {% csrf_token %}
90 <input type="hidden" name="action" value="create">
91
92 <div>
93 <label class="block text-sm font-medium text-gray-300 mb-1">Git Remote URL <span class="text-red-400">*</span></label>
94 <input type="text" name="git_remote_url" required placeholder="https://github.com/org/repo.git"
 
 
95 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
96 </div>
97
98 <div class="grid grid-cols-2 gap-4">
99 <div>
100 <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label>
101 <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
102 <option value="token">Personal Access Token</option>
103 <option value="ssh">SSH Key</option>
104 <option value="oauth_github">GitHub OAuth</option>
105 <option value="oauth_gitlab">GitLab OAuth</option>
106 </select>
107 </div>
108 <div>
109 <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label>
110 <input type="text" name="git_branch" value="main"
111 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2">
 
 
 
112 </div>
113 </div>
114
115 <div>
116 <label class="block text-sm font-medium text-gray-300 mb-1">Token / Credential</label>
117 <input type="password" name="auth_credential" placeholder="ghp_xxxxxxxxxxxx"
 
118 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
119 <p class="mt-1 text-xs text-gray-500">For token auth: GitHub Personal Access Token or GitLab Access Token</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120 </div>
121
122 <div class="grid grid-cols-2 gap-4">
123 <div>
124 <label class="block text-sm font-medium text-gray-300 mb-1">Sync Mode</label>
125 <select name="sync_mode" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
126 <option value="scheduled">Scheduled</option>
127 <option value="on_change">On Change</option>
128 <option value="both">Both</option>
129 </select>
130 </div>
131 <div>
132 <label class="block text-sm font-medium text-gray-300 mb-1">Schedule (cron)</label>
133 <input type="text" name="sync_schedule" value="*/15 * * * *"
 
134 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
135 <p class="mt-1 text-xs text-gray-500">Default: every 15 minutes</p>
136 </div>
137 </div>
138
139 <div class="flex justify-end pt-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
141 Add Mirror
142 </button>
143 </div>
144 </form>
145 </div>
146 </div>
147 {% endblock %}
148
149 DDED templates/fossil/git_mirror_delete.html
--- templates/fossil/git_mirror.html
+++ templates/fossil/git_mirror.html
@@ -1,66 +1,20 @@
1 {% extends "base.html" %}
2 {% block title %}{% if editing_mirror %}Edit Mirror{% else %}Add Git Mirror{% endif %} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="max-w-3xl">
9 <div class="flex items-center justify-between mb-6">
10 <h2 class="text-lg font-semibold text-gray-200">{% if editing_mirror %}Edit Git Mirror{% else %}Add Git Mirror{% endif %}</h2>
11 <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Sync</a>
12 </div>
13
14 {# ── OAuth quick-connect (only shown when adding) ──────────────── #}
15 {% if not editing_mirror %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6">
17 <h3 class="text-sm font-semibold text-gray-200 mb-3 text-center">Quick Connect</h3>
18 <div class="flex items-center justify-center gap-3">
19 <a href="{% url 'fossil:oauth_github' slug=project.slug %}"
20 class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-gray-200 ring-1 ring-inset ring-gray-600 hover:bg-gray-700">
@@ -72,76 +26,119 @@
26 <svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg>
27 Connect GitLab
28 </a>
29 </div>
30 {% if request.session.github_oauth_token %}
31 <p class="mt-2 text-xs text-green-400 text-center">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p>
32 {% endif %}
33 {% if request.session.gitlab_oauth_token %}
34 <p class="mt-2 text-xs text-green-400 text-center">GitLab connected. Token will be used for new mirrors.</p>
35 {% endif %}
36 </div>
37 {% endif %}
38
39 {# ── Mirror form (add or edit) ─────────────────────────────────── #}
40 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
 
 
41 <form method="post" class="space-y-4">
42 {% csrf_token %}
43 <input type="hidden" name="action" value="{% if editing_mirror %}update{% else %}create{% endif %}">
44
45 <div>
46 <label class="block text-sm font-medium text-gray-300 mb-1">Git Remote URL <span class="text-red-400">*</span></label>
47 <input type="text" name="git_remote_url" required
48 value="{{ editing_mirror.git_remote_url|default:'' }}"
49 placeholder="https://github.com/org/repo.git"
50 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
51 </div>
52
53 <div class="grid grid-cols-2 gap-4">
54 <div>
55 <label class="block text-sm font-medium text-gray-300 mb-1">Sync Direction</label>
56 <select name="sync_direction" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
57 {% for value, label in sync_direction_choices %}
58 <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_direction == value %}selected{% endif %}>{{ label }}</option>
59 {% endfor %}
 
60 </select>
61 </div>
62 <div>
63 <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label>
64 <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
65 {% for value, label in auth_method_choices %}
66 <option value="{{ value }}" {% if editing_mirror and editing_mirror.auth_method == value %}selected{% endif %}>{{ label }}</option>
67 {% endfor %}
68 </select>
69 </div>
70 </div>
71
72 <div>
73 <label class="block text-sm font-medium text-gray-300 mb-1">Token / Credential</label>
74 <input type="password" name="auth_credential"
75 placeholder="{% if editing_mirror %}Leave blank to keep current credential{% else %}ghp_xxxxxxxxxxxx{% endif %}"
76 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
77 <p class="mt-1 text-xs text-gray-500">
78 {% if editing_mirror %}Leave blank to keep the existing credential.{% else %}For token auth: GitHub Personal Access Token or GitLab Access Token.{% endif %}
79 </p>
80 </div>
81
82 <div class="grid grid-cols-2 gap-4">
83 <div>
84 <label class="block text-sm font-medium text-gray-300 mb-1">Fossil Branch</label>
85 <input type="text" name="fossil_branch"
86 value="{{ editing_mirror.fossil_branch|default:'trunk' }}"
87 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2">
88 </div>
89 <div>
90 <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label>
91 <input type="text" name="git_branch"
92 value="{{ editing_mirror.git_branch|default:'main' }}"
93 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2">
94 </div>
95 </div>
96
97 <div class="grid grid-cols-2 gap-4">
98 <div>
99 <label class="block text-sm font-medium text-gray-300 mb-1">Sync Mode</label>
100 <select name="sync_mode" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
101 {% for value, label in sync_mode_choices %}
102 <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_mode == value %}selected{% endif %}>{{ label }}</option>
103 {% endfor %}
104 </select>
105 </div>
106 <div>
107 <label class="block text-sm font-medium text-gray-300 mb-1">Schedule (cron)</label>
108 <input type="text" name="sync_schedule"
109 value="{{ editing_mirror.sync_schedule|default:'*/15 * * * *' }}"
110 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono">
111 <p class="mt-1 text-xs text-gray-500">Default: every 15 minutes</p>
112 </div>
113 </div>
114
115 {# Sync content options #}
116 <div>
117 <span class="block text-sm font-medium text-gray-300 mb-2">Sync Content</span>
118 <div class="flex items-center gap-6">
119 <label class="flex items-center gap-2 text-sm text-gray-300">
120 <input type="checkbox" name="sync_tickets"
121 {% if editing_mirror and editing_mirror.sync_tickets %}checked{% endif %}
122 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
123 Tickets as Issues
124 </label>
125 <label class="flex items-center gap-2 text-sm text-gray-300">
126 <input type="checkbox" name="sync_wiki"
127 {% if editing_mirror and editing_mirror.sync_wiki %}checked{% endif %}
128 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
129 Wiki pages
130 </label>
131 </div>
132 <p class="mt-1 text-xs text-gray-500">Code is always synced. Optionally sync tickets and wiki.</p>
133 </div>
134
135 <div class="flex items-center justify-between pt-2">
136 <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-gray-400 hover:text-gray-200">Cancel</a>
137 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
138 {% if editing_mirror %}Update Mirror{% else %}Add Mirror{% endif %}
139 </button>
140 </div>
141 </form>
142 </div>
143 </div>
144 {% endblock %}
145
146 DDED templates/fossil/git_mirror_delete.html
--- a/templates/fossil/git_mirror_delete.html
+++ b/templates/fossil/git_mirror_delete.html
@@ -0,0 +1,43 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete Mirror — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mx-auto max-w-lg">
9
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
10
+ <h2 class="text-lg font-semibold text-gray-100">Delete Git Mirror</h2>
11
+ <p class="mt-2 text-sm text-gray-400">
12
+ Are you sure you want to remove the mirror to
13
+ <strong class="text-gray-100 font-mono">{{ mirror.git_remote_url }}</strong>?
14
+ This action uses soft delete &mdash; the record will be marked as deleted but can be recovered.
15
+ </p>
16
+ <dl class="mt-4 space-y-2 text-sm">
17
+ <div class="flex items-center justify-between">
18
+ <dt class="text-gray-500">Direction</dt>
19
+ <dd class="text-gray-300">{{ mirror.get_sync_direction_display }}</dd>
20
+ </div>
21
+ <div class="flex items-center justify-between">
22
+ <dt class="text-gray-500">Branch mapping</dt>
23
+ <dd class="text-gray-300">{{ mirror.fossil_branch }} &rarr; {{ mirror.git_branch }}</dd>
24
+ </div>
25
+ <div class="flex items-center justify-between">
26
+ <dt class="text-gray-500">Total syncs</dt>
27
+ <dd class="text-gray-300">{{ mirror.total_syncs }}</dd>
28
+ </div>
29
+ </dl>
30
+ <form method="post" class="mt-6 flex justify-end gap-3">
31
+ {% csrf_token %}
32
+ <a href="{% url 'fossil:sync' slug=project.slug %}"
33
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
34
+ Cancel
35
+ </a>
36
+ <button type="submit"
37
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
38
+ Delete Mirror
39
+ </button>
40
+ </form>
41
+ </div>
42
+</div>
43
+{% endblock %}
--- a/templates/fossil/git_mirror_delete.html
+++ b/templates/fossil/git_mirror_delete.html
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/git_mirror_delete.html
+++ b/templates/fossil/git_mirror_delete.html
@@ -0,0 +1,43 @@
1 {% extends "base.html" %}
2 {% block title %}Delete Mirror — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mx-auto max-w-lg">
9 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
10 <h2 class="text-lg font-semibold text-gray-100">Delete Git Mirror</h2>
11 <p class="mt-2 text-sm text-gray-400">
12 Are you sure you want to remove the mirror to
13 <strong class="text-gray-100 font-mono">{{ mirror.git_remote_url }}</strong>?
14 This action uses soft delete &mdash; the record will be marked as deleted but can be recovered.
15 </p>
16 <dl class="mt-4 space-y-2 text-sm">
17 <div class="flex items-center justify-between">
18 <dt class="text-gray-500">Direction</dt>
19 <dd class="text-gray-300">{{ mirror.get_sync_direction_display }}</dd>
20 </div>
21 <div class="flex items-center justify-between">
22 <dt class="text-gray-500">Branch mapping</dt>
23 <dd class="text-gray-300">{{ mirror.fossil_branch }} &rarr; {{ mirror.git_branch }}</dd>
24 </div>
25 <div class="flex items-center justify-between">
26 <dt class="text-gray-500">Total syncs</dt>
27 <dd class="text-gray-300">{{ mirror.total_syncs }}</dd>
28 </div>
29 </dl>
30 <form method="post" class="mt-6 flex justify-end gap-3">
31 {% csrf_token %}
32 <a href="{% url 'fossil:sync' slug=project.slug %}"
33 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
34 Cancel
35 </a>
36 <button type="submit"
37 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
38 Delete Mirror
39 </button>
40 </form>
41 </div>
42 </div>
43 {% endblock %}
--- templates/fossil/sync.html
+++ templates/fossil/sync.html
@@ -3,13 +3,14 @@
33
44
{% block content %}
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
77
8
-<div class="max-w-2xl">
8
+<div class="max-w-3xl">
9
+
10
+ {# ── Upstream Fossil Sync ────────────────────────────────────────── #}
911
{% if sync_configured %}
10
- <!-- Sync is configured — show status and pull button -->
1112
<div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6">
1213
<div class="flex items-center justify-between mb-4">
1314
<h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2>
1415
<span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Configured</span>
1516
</div>
@@ -48,18 +49,15 @@
4849
<input type="hidden" name="action" value="disable">
4950
<button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
5051
Disable Sync
5152
</button>
5253
</form>
53
- <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
54
- Git Mirrors
55
- </a>
5654
</div>
5755
</div>
5856
5957
{% if result %}
60
- <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4">
58
+ <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4 mb-6">
6159
<div class="text-sm {% if result.success %}text-green-300{% else %}text-red-300{% endif %}">
6260
{% if result.success %}
6361
{% if result.artifacts_received > 0 %}Pulled {{ result.artifacts_received }} new artifacts.{% else %}Already up to date.{% endif %}
6462
{% else %}
6563
Sync failed: {{ result.message }}
@@ -68,12 +66,12 @@
6866
{% if result.message %}<pre class="mt-2 text-xs text-gray-500 font-mono">{{ result.message }}</pre>{% endif %}
6967
</div>
7068
{% endif %}
7169
7270
{% else %}
73
- <!-- Sync not configured — show setup wizard -->
74
- <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
71
+ {# ── Upstream not configured ─────────────────────────────────────── #}
72
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
7573
<div class="text-center mb-6">
7674
<svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
7775
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
7876
</svg>
7977
<h2 class="mt-3 text-lg font-semibold text-gray-200">Configure Upstream Sync</h2>
@@ -105,16 +103,108 @@
105103
<li>You can also pull manually at any time</li>
106104
<li>Your local data is never overwritten — only new artifacts are added</li>
107105
</ul>
108106
</div>
109107
110
- <div class="flex items-center justify-between">
111
- <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">Or configure Git Mirror (GitHub/GitLab) &rarr;</a>
108
+ <div class="flex items-center justify-end">
112109
<button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
113110
Enable Sync
114111
</button>
115112
</div>
116113
</form>
117114
</div>
118115
{% endif %}
116
+
117
+ {# ── Git Mirrors ─────────────────────────────────────────────────── #}
118
+ <div class="mb-4 flex items-center justify-between">
119
+ <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2>
120
+ <a href="{% url 'fossil:git_mirror' slug=project.slug %}"
121
+ class="inline-flex items-center gap-1 rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
122
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
123
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
124
+ </svg>
125
+ Add Mirror
126
+ </a>
127
+ </div>
128
+
129
+ {% if mirrors %}
130
+ <div class="space-y-4">
131
+ {% for mirror in mirrors %}
132
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
133
+ <div class="flex items-start justify-between gap-4">
134
+ <div class="min-w-0 flex-1">
135
+ {# URL and direction badge #}
136
+ <div class="flex items-center gap-2 flex-wrap">
137
+ <span class="font-mono text-sm text-gray-200 truncate">{{ mirror.git_remote_url }}</span>
138
+ <span class="inline-flex shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
139
+ {% if mirror.sync_direction == 'push' %}bg-blue-900/50 text-blue-300
140
+ {% elif mirror.sync_direction == 'pull' %}bg-purple-900/50 text-purple-300
141
+ {% else %}bg-teal-900/50 text-teal-300{% endif %}">
142
+ {{ mirror.get_sync_direction_display }}
143
+ </span>
144
+ </div>
145
+
146
+ {# Detail line #}
147
+ <div class="mt-2 flex items-center gap-3 flex-wrap text-xs text-gray-500">
148
+ <span class="inline-flex items-center gap-1">
149
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
150
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
151
+ </svg>
152
+ {% if mirror.sync_mode == 'disabled' %}Disabled{% else %}{{ mirror.get_sync_mode_display }}{% endif %}
153
+ </span>
154
+ {% if mirror.sync_mode != 'disabled' and mirror.sync_mode != 'on_change' %}
155
+ <span class="font-mono">{{ mirror.sync_schedule }}</span>
156
+ {% endif %}
157
+ <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span>
158
+ <span>{{ mirror.fossil_branch }} &rarr; {{ mirror.git_branch }}</span>
159
+ </div>
160
+
161
+ {# Sync status #}
162
+ <div class="mt-2 text-xs text-gray-500">
163
+ {% if mirror.last_sync_at %}
164
+ Last sync: {{ mirror.last_sync_at|timesince }} ago
165
+ {% if mirror.last_sync_status == 'success' %}
166
+ <span class="text-green-400">&#10003;</span>
167
+ {% elif mirror.last_sync_status == 'failed' %}
168
+ <span class="text-red-400">&#10007;</span>
169
+ {% elif mirror.last_sync_status %}
170
+ <span class="text-yellow-400">({{ mirror.last_sync_status }})</span>
171
+ {% endif %}
172
+ &mdash; {{ mirror.total_syncs }} total sync{{ mirror.total_syncs|pluralize:"es" }}
173
+ {% else %}
174
+ Never synced
175
+ {% endif %}
176
+ </div>
177
+ {% if mirror.last_sync_message and mirror.last_sync_status == 'failed' %}
178
+ <pre class="mt-1 text-xs text-red-400/70 max-h-16 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre>
179
+ {% endif %}
180
+ </div>
181
+
182
+ {# Action buttons #}
183
+ <div class="flex items-center gap-2 shrink-0">
184
+ <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}">
185
+ {% csrf_token %}
186
+ <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover"
187
+ title="Run sync now">Run Now</button>
188
+ </form>
189
+ <a href="{% url 'fossil:git_mirror_edit' slug=project.slug mirror_id=mirror.id %}"
190
+ class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"
191
+ title="Edit mirror">Edit</a>
192
+ <a href="{% url 'fossil:git_mirror_delete' slug=project.slug mirror_id=mirror.id %}"
193
+ class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"
194
+ title="Delete mirror">Delete</a>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ {% endfor %}
199
+ </div>
200
+ {% else %}
201
+ <div class="rounded-lg bg-gray-800 border border-gray-700 border-dashed p-8 text-center">
202
+ <svg class="mx-auto h-10 w-10 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
203
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
204
+ </svg>
205
+ <p class="mt-3 text-sm text-gray-400">No Git mirrors configured.</p>
206
+ <p class="mt-1 text-xs text-gray-500">Mirror this Fossil repository to GitHub, GitLab, or any Git remote.</p>
207
+ </div>
208
+ {% endif %}
119209
</div>
120210
{% endblock %}
121211
--- templates/fossil/sync.html
+++ templates/fossil/sync.html
@@ -3,13 +3,14 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="max-w-2xl">
 
 
9 {% if sync_configured %}
10 <!-- Sync is configured — show status and pull button -->
11 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6">
12 <div class="flex items-center justify-between mb-4">
13 <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2>
14 <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Configured</span>
15 </div>
@@ -48,18 +49,15 @@
48 <input type="hidden" name="action" value="disable">
49 <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
50 Disable Sync
51 </button>
52 </form>
53 <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
54 Git Mirrors
55 </a>
56 </div>
57 </div>
58
59 {% if result %}
60 <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4">
61 <div class="text-sm {% if result.success %}text-green-300{% else %}text-red-300{% endif %}">
62 {% if result.success %}
63 {% if result.artifacts_received > 0 %}Pulled {{ result.artifacts_received }} new artifacts.{% else %}Already up to date.{% endif %}
64 {% else %}
65 Sync failed: {{ result.message }}
@@ -68,12 +66,12 @@
68 {% if result.message %}<pre class="mt-2 text-xs text-gray-500 font-mono">{{ result.message }}</pre>{% endif %}
69 </div>
70 {% endif %}
71
72 {% else %}
73 <!-- Sync not configured — show setup wizard -->
74 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
75 <div class="text-center mb-6">
76 <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
77 <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
78 </svg>
79 <h2 class="mt-3 text-lg font-semibold text-gray-200">Configure Upstream Sync</h2>
@@ -105,16 +103,108 @@
105 <li>You can also pull manually at any time</li>
106 <li>Your local data is never overwritten — only new artifacts are added</li>
107 </ul>
108 </div>
109
110 <div class="flex items-center justify-between">
111 <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">Or configure Git Mirror (GitHub/GitLab) &rarr;</a>
112 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
113 Enable Sync
114 </button>
115 </div>
116 </form>
117 </div>
118 {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119 </div>
120 {% endblock %}
121
--- templates/fossil/sync.html
+++ templates/fossil/sync.html
@@ -3,13 +3,14 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="max-w-3xl">
9
10 {# ── Upstream Fossil Sync ────────────────────────────────────────── #}
11 {% if sync_configured %}
 
12 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6">
13 <div class="flex items-center justify-between mb-4">
14 <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2>
15 <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Configured</span>
16 </div>
@@ -48,18 +49,15 @@
49 <input type="hidden" name="action" value="disable">
50 <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
51 Disable Sync
52 </button>
53 </form>
 
 
 
54 </div>
55 </div>
56
57 {% if result %}
58 <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4 mb-6">
59 <div class="text-sm {% if result.success %}text-green-300{% else %}text-red-300{% endif %}">
60 {% if result.success %}
61 {% if result.artifacts_received > 0 %}Pulled {{ result.artifacts_received }} new artifacts.{% else %}Already up to date.{% endif %}
62 {% else %}
63 Sync failed: {{ result.message }}
@@ -68,12 +66,12 @@
66 {% if result.message %}<pre class="mt-2 text-xs text-gray-500 font-mono">{{ result.message }}</pre>{% endif %}
67 </div>
68 {% endif %}
69
70 {% else %}
71 {# ── Upstream not configured ─────────────────────────────────────── #}
72 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
73 <div class="text-center mb-6">
74 <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
75 <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
76 </svg>
77 <h2 class="mt-3 text-lg font-semibold text-gray-200">Configure Upstream Sync</h2>
@@ -105,16 +103,108 @@
103 <li>You can also pull manually at any time</li>
104 <li>Your local data is never overwritten — only new artifacts are added</li>
105 </ul>
106 </div>
107
108 <div class="flex items-center justify-end">
 
109 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
110 Enable Sync
111 </button>
112 </div>
113 </form>
114 </div>
115 {% endif %}
116
117 {# ── Git Mirrors ─────────────────────────────────────────────────── #}
118 <div class="mb-4 flex items-center justify-between">
119 <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2>
120 <a href="{% url 'fossil:git_mirror' slug=project.slug %}"
121 class="inline-flex items-center gap-1 rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
122 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
123 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
124 </svg>
125 Add Mirror
126 </a>
127 </div>
128
129 {% if mirrors %}
130 <div class="space-y-4">
131 {% for mirror in mirrors %}
132 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
133 <div class="flex items-start justify-between gap-4">
134 <div class="min-w-0 flex-1">
135 {# URL and direction badge #}
136 <div class="flex items-center gap-2 flex-wrap">
137 <span class="font-mono text-sm text-gray-200 truncate">{{ mirror.git_remote_url }}</span>
138 <span class="inline-flex shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
139 {% if mirror.sync_direction == 'push' %}bg-blue-900/50 text-blue-300
140 {% elif mirror.sync_direction == 'pull' %}bg-purple-900/50 text-purple-300
141 {% else %}bg-teal-900/50 text-teal-300{% endif %}">
142 {{ mirror.get_sync_direction_display }}
143 </span>
144 </div>
145
146 {# Detail line #}
147 <div class="mt-2 flex items-center gap-3 flex-wrap text-xs text-gray-500">
148 <span class="inline-flex items-center gap-1">
149 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
150 <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
151 </svg>
152 {% if mirror.sync_mode == 'disabled' %}Disabled{% else %}{{ mirror.get_sync_mode_display }}{% endif %}
153 </span>
154 {% if mirror.sync_mode != 'disabled' and mirror.sync_mode != 'on_change' %}
155 <span class="font-mono">{{ mirror.sync_schedule }}</span>
156 {% endif %}
157 <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span>
158 <span>{{ mirror.fossil_branch }} &rarr; {{ mirror.git_branch }}</span>
159 </div>
160
161 {# Sync status #}
162 <div class="mt-2 text-xs text-gray-500">
163 {% if mirror.last_sync_at %}
164 Last sync: {{ mirror.last_sync_at|timesince }} ago
165 {% if mirror.last_sync_status == 'success' %}
166 <span class="text-green-400">&#10003;</span>
167 {% elif mirror.last_sync_status == 'failed' %}
168 <span class="text-red-400">&#10007;</span>
169 {% elif mirror.last_sync_status %}
170 <span class="text-yellow-400">({{ mirror.last_sync_status }})</span>
171 {% endif %}
172 &mdash; {{ mirror.total_syncs }} total sync{{ mirror.total_syncs|pluralize:"es" }}
173 {% else %}
174 Never synced
175 {% endif %}
176 </div>
177 {% if mirror.last_sync_message and mirror.last_sync_status == 'failed' %}
178 <pre class="mt-1 text-xs text-red-400/70 max-h-16 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre>
179 {% endif %}
180 </div>
181
182 {# Action buttons #}
183 <div class="flex items-center gap-2 shrink-0">
184 <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}">
185 {% csrf_token %}
186 <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover"
187 title="Run sync now">Run Now</button>
188 </form>
189 <a href="{% url 'fossil:git_mirror_edit' slug=project.slug mirror_id=mirror.id %}"
190 class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"
191 title="Edit mirror">Edit</a>
192 <a href="{% url 'fossil:git_mirror_delete' slug=project.slug mirror_id=mirror.id %}"
193 class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"
194 title="Delete mirror">Delete</a>
195 </div>
196 </div>
197 </div>
198 {% endfor %}
199 </div>
200 {% else %}
201 <div class="rounded-lg bg-gray-800 border border-gray-700 border-dashed p-8 text-center">
202 <svg class="mx-auto h-10 w-10 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
203 <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
204 </svg>
205 <p class="mt-3 text-sm text-gray-400">No Git mirrors configured.</p>
206 <p class="mt-1 text-xs text-gray-500">Mirror this Fossil repository to GitHub, GitLab, or any Git remote.</p>
207 </div>
208 {% endif %}
209 </div>
210 {% endblock %}
211
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -47,10 +47,11 @@
4747
{{ user.get_full_name|default:user.username }}
4848
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
4949
</button>
5050
<div x-show="open" @click.outside="open = false" x-transition
5151
class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
52
+ <a href="{% url 'accounts:profile' %}" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Profile</a>
5253
<form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
5354
</div>
5455
</div>
5556
</div>
5657
</div>
5758
5859
ADDED tests/test_git_mirrors.py
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -47,10 +47,11 @@
47 {{ user.get_full_name|default:user.username }}
48 <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
49 </button>
50 <div x-show="open" @click.outside="open = false" x-transition
51 class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
 
52 <form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
53 </div>
54 </div>
55 </div>
56 </div>
57
58 DDED tests/test_git_mirrors.py
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -47,10 +47,11 @@
47 {{ user.get_full_name|default:user.username }}
48 <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
49 </button>
50 <div x-show="open" @click.outside="open = false" x-transition
51 class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
52 <a href="{% url 'accounts:profile' %}" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Profile</a>
53 <form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
54 </div>
55 </div>
56 </div>
57 </div>
58
59 DDED tests/test_git_mirrors.py
--- a/tests/test_git_mirrors.py
+++ b/tests/test_git_mirrors.py
@@ -0,0 +1,382 @@
1
+"""Tests for Git mirror multi-remote sync UI views."""
2
+
3
+from unittest.mock import MagicMock, patch
4
+
5
+import pytest
6
+from django.contrib.auth.models import User
7
+from django.test import Client
8
+
9
+from fossil.models import FossilRepository
10
+from fossil.sync_models import GitMirror
11
+from organization.models import Team
12
+from projects.models import ProjectTeam
13
+
14
+# Reusable patch that makes FossilRepository.exists_on_disk return True
15
+_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
16
+
17
+
18
+def _make_reader_mock(**methods):
19
+ """Create a MagicMock that replaces FossilReader as a class."""
20
+ mock_cls = MagicMock()
21
+ instance = MagicMock()
22
+ mock_cls.return_value = instance
23
+ instance.__enter__ = MagicMock(return_value=instance)
24
+ instance.__exit__ = MagicMock(return_value=False)
25
+ for name, val in methods.items():
26
+ getattr(instance, name).return_value = val
27
+ return mock_cls
28
+
29
+
30
+@pytest.fixture
31
+def fossil_repo_obj(sample_project):
32
+ """Return the auto-created FossilRepository for sample_project."""
33
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
34
+
35
+
36
+@pytest.fixture
37
+def mirror(fossil_repo_obj, admin_user):
38
+ return GitMirror.objects.create(
39
+ repository=fossil_repo_obj,
40
+ git_remote_url="https://github.com/org/repo.git",
41
+ auth_method="token",
42
+ auth_credential="ghp_test123",
43
+ sync_direction="push",
44
+ sync_mode="scheduled",
45
+ sync_schedule="*/15 * * * *",
46
+ git_branch="main",
47
+ fossil_branch="trunk",
48
+ created_by=admin_user,
49
+ )
50
+
51
+
52
+@pytest.fixture
53
+def second_mirror(fossil_repo_obj, admin_user):
54
+ return GitMirror.objects.create(
55
+ repository=fossil_repo_obj,
56
+ git_remote_url="https://gitlab.com/org/repo.git",
57
+ auth_method="oauth_gitlab",
58
+ sync_direction="both",
59
+ sync_mode="both",
60
+ sync_schedule="0 */6 * * *",
61
+ git_branch="main",
62
+ fossil_branch="trunk",
63
+ created_by=admin_user,
64
+ )
65
+
66
+
67
+@pytest.fixture
68
+def writer_user(db, admin_user, sample_project):
69
+ """User with write access but not admin."""
70
+ writer = User.objects.create_user(username="mirror_writer", password="testpass123")
71
+ team = Team.objects.create(name="Mirror Writers", organization=sample_project.organization, created_by=admin_user)
72
+ team.members.add(writer)
73
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
74
+ return writer
75
+
76
+
77
+@pytest.fixture
78
+def writer_client(writer_user):
79
+ client = Client()
80
+ client.login(username="mirror_writer", password="testpass123")
81
+ return client
82
+
83
+
84
+# --- GitMirror Model Tests ---
85
+
86
+
87
+@pytest.mark.django_db
88
+class TestGitMirrorModel:
89
+ def test_create_mirror(self, mirror):
90
+ assert mirror.pk is not None
91
+ assert mirror.git_remote_url == "https://github.com/org/repo.git"
92
+ assert mirror.sync_direction == "push"
93
+
94
+ def test_str_representation(self, mirror):
95
+ assert "github.com/org/repo.git" in str(mirror)
96
+
97
+ def test_soft_delete(self, mirror, admin_user):
98
+ mirror.soft_delete(user=admin_user)
99
+ assert mirror.is_deleted
100
+ assert GitMirror.objects.filter(pk=mirror.pk).count() == 0
101
+ assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1
102
+
103
+ def test_multiple_mirrors_per_repo(self, mirror, second_mirror, fossil_repo_obj):
104
+ mirrors = GitMirror.objects.filter(repository=fossil_repo_obj)
105
+ assert mirrors.count() == 2
106
+
107
+ def test_ordering(self, mirror, second_mirror):
108
+ """Mirrors are ordered newest first."""
109
+ mirrors = list(GitMirror.objects.all())
110
+ assert mirrors[0] == second_mirror
111
+ assert mirrors[1] == mirror
112
+
113
+
114
+# --- Sync Page (sync.html) showing mirrors ---
115
+
116
+
117
+@pytest.mark.django_db
118
+class TestSyncPageMirrorListing:
119
+ def test_sync_page_shows_mirrors(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror):
120
+ mock = _make_reader_mock()
121
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
122
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
123
+ assert response.status_code == 200
124
+ content = response.content.decode()
125
+ assert "github.com/org/repo.git" in content
126
+ assert "gitlab.com/org/repo.git" in content
127
+ assert "Git Mirrors" in content
128
+
129
+ def test_sync_page_shows_empty_state(self, admin_client, sample_project, fossil_repo_obj):
130
+ mock = _make_reader_mock()
131
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
132
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
133
+ assert response.status_code == 200
134
+ content = response.content.decode()
135
+ assert "No Git mirrors configured" in content
136
+
137
+ def test_sync_page_shows_add_mirror_button(self, admin_client, sample_project, fossil_repo_obj):
138
+ mock = _make_reader_mock()
139
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
140
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
141
+ assert response.status_code == 200
142
+ assert "Add Mirror" in response.content.decode()
143
+
144
+ def test_sync_page_shows_mirror_direction_badges(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror):
145
+ mock = _make_reader_mock()
146
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
147
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
148
+ content = response.content.decode()
149
+ # Check direction labels rendered
150
+ assert "Push" in content
151
+ assert "Bidirectional" in content
152
+
153
+ def test_sync_page_shows_edit_delete_links(self, admin_client, sample_project, fossil_repo_obj, mirror):
154
+ mock = _make_reader_mock()
155
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
156
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
157
+ content = response.content.decode()
158
+ assert "Edit" in content
159
+ assert "Delete" in content
160
+ assert "Run Now" in content
161
+
162
+ def test_sync_page_denied_for_anon(self, client, sample_project):
163
+ response = client.get(f"/projects/{sample_project.slug}/fossil/sync/")
164
+ assert response.status_code == 302 # redirect to login
165
+
166
+
167
+# --- Git Mirror Config View (Add) ---
168
+
169
+
170
+@pytest.mark.django_db
171
+class TestGitMirrorAddView:
172
+ def test_get_add_form(self, admin_client, sample_project, fossil_repo_obj):
173
+ mock = _make_reader_mock()
174
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
175
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/")
176
+ assert response.status_code == 200
177
+ content = response.content.decode()
178
+ assert "Add Git Mirror" in content
179
+ assert "Quick Connect" in content
180
+
181
+ def test_create_mirror(self, admin_client, sample_project, fossil_repo_obj):
182
+ mock = _make_reader_mock()
183
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
184
+ response = admin_client.post(
185
+ f"/projects/{sample_project.slug}/fossil/sync/git/",
186
+ {
187
+ "action": "create",
188
+ "git_remote_url": "https://github.com/test/new-repo.git",
189
+ "auth_method": "token",
190
+ "auth_credential": "ghp_newtoken",
191
+ "sync_direction": "push",
192
+ "sync_mode": "scheduled",
193
+ "sync_schedule": "*/30 * * * *",
194
+ "git_branch": "main",
195
+ "fossil_branch": "trunk",
196
+ },
197
+ )
198
+ assert response.status_code == 302
199
+ mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/new-repo.git")
200
+ assert mirror.sync_direction == "push"
201
+ assert mirror.sync_schedule == "*/30 * * * *"
202
+ assert mirror.fossil_branch == "trunk"
203
+
204
+ def test_create_mirror_with_sync_tickets(self, admin_client, sample_project, fossil_repo_obj):
205
+ mock = _make_reader_mock()
206
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
207
+ response = admin_client.post(
208
+ f"/projects/{sample_project.slug}/fossil/sync/git/",
209
+ {
210
+ "action": "create",
211
+ "git_remote_url": "https://github.com/test/tickets-repo.git",
212
+ "auth_method": "token",
213
+ "sync_direction": "push",
214
+ "sync_mode": "scheduled",
215
+ "sync_schedule": "*/15 * * * *",
216
+ "git_branch": "main",
217
+ "fossil_branch": "trunk",
218
+ "sync_tickets": "on",
219
+ "sync_wiki": "on",
220
+ },
221
+ )
222
+ assert response.status_code == 302
223
+ mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/tickets-repo.git")
224
+ assert mirror.sync_tickets is True
225
+ assert mirror.sync_wiki is True
226
+
227
+ def test_create_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
228
+ response = writer_client.post(
229
+ f"/projects/{sample_project.slug}/fossil/sync/git/",
230
+ {"action": "create", "git_remote_url": "https://evil.com/repo.git"},
231
+ )
232
+ assert response.status_code == 403
233
+
234
+ def test_create_denied_for_anon(self, client, sample_project):
235
+ response = client.post(
236
+ f"/projects/{sample_project.slug}/fossil/sync/git/",
237
+ {"action": "create", "git_remote_url": "https://example.com/repo.git"},
238
+ )
239
+ assert response.status_code == 302 # redirect to login
240
+
241
+
242
+# --- Git Mirror Config View (Edit) ---
243
+
244
+
245
+@pytest.mark.django_db
246
+class TestGitMirrorEditView:
247
+ def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj, mirror):
248
+ mock = _make_reader_mock()
249
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
250
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/")
251
+ assert response.status_code == 200
252
+ content = response.content.decode()
253
+ assert "Edit Git Mirror" in content
254
+ assert "github.com/org/repo.git" in content
255
+ # Should NOT show quick connect section when editing
256
+ assert "Quick Connect" not in content
257
+
258
+ def test_edit_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
259
+ mock = _make_reader_mock()
260
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
261
+ response = admin_client.post(
262
+ f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
263
+ {
264
+ "action": "update",
265
+ "git_remote_url": "https://github.com/org/updated-repo.git",
266
+ "auth_method": "ssh",
267
+ "sync_direction": "both",
268
+ "sync_mode": "both",
269
+ "sync_schedule": "0 */2 * * *",
270
+ "git_branch": "develop",
271
+ "fossil_branch": "trunk",
272
+ },
273
+ )
274
+ assert response.status_code == 302
275
+ mirror.refresh_from_db()
276
+ assert mirror.git_remote_url == "https://github.com/org/updated-repo.git"
277
+ assert mirror.auth_method == "ssh"
278
+ assert mirror.sync_direction == "both"
279
+ assert mirror.sync_schedule == "0 */2 * * *"
280
+ assert mirror.git_branch == "develop"
281
+
282
+ def test_edit_preserves_credential_when_blank(self, admin_client, sample_project, fossil_repo_obj, mirror):
283
+ """Editing without providing a new credential should keep the old one."""
284
+ old_credential = mirror.auth_credential
285
+ mock = _make_reader_mock()
286
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
287
+ response = admin_client.post(
288
+ f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
289
+ {
290
+ "action": "update",
291
+ "git_remote_url": "https://github.com/org/repo.git",
292
+ "auth_method": "token",
293
+ "auth_credential": "",
294
+ "sync_direction": "push",
295
+ "sync_mode": "scheduled",
296
+ "sync_schedule": "*/15 * * * *",
297
+ "git_branch": "main",
298
+ "fossil_branch": "trunk",
299
+ },
300
+ )
301
+ assert response.status_code == 302
302
+ mirror.refresh_from_db()
303
+ assert mirror.auth_credential == old_credential
304
+
305
+ def test_edit_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj):
306
+ mock = _make_reader_mock()
307
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
308
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/99999/edit/")
309
+ assert response.status_code == 404
310
+
311
+ def test_edit_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
312
+ response = writer_client.post(
313
+ f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
314
+ {"action": "update", "git_remote_url": "https://evil.com/repo.git"},
315
+ )
316
+ assert response.status_code == 403
317
+
318
+
319
+# --- Git Mirror Delete View ---
320
+
321
+
322
+@pytest.mark.django_db
323
+class TestGitMirrorDeleteView:
324
+ def test_get_delete_confirmation(self, admin_client, sample_project, fossil_repo_obj, mirror):
325
+ mock = _make_reader_mock()
326
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
327
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
328
+ assert response.status_code == 200
329
+ content = response.content.decode()
330
+ assert "Delete Git Mirror" in content
331
+ assert "github.com/org/repo.git" in content
332
+
333
+ def test_delete_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
334
+ mock = _make_reader_mock()
335
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
336
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
337
+ assert response.status_code == 302
338
+ mirror.refresh_from_db()
339
+ assert mirror.is_deleted
340
+
341
+ def test_delete_removes_from_active_queries(self, admin_client, sample_project, fossil_repo_obj, mirror):
342
+ mock = _make_reader_mock()
343
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
344
+ admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
345
+ assert GitMirror.objects.filter(pk=mirror.pk).count() == 0
346
+ assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1
347
+
348
+ def test_delete_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj):
349
+ mock = _make_reader_mock()
350
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
351
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/99999/delete/")
352
+ assert response.status_code == 404
353
+
354
+ def test_delete_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
355
+ response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
356
+ assert response.status_code == 403
357
+
358
+ def test_delete_denied_for_anon(self, client, sample_project, mirror):
359
+ response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
360
+ assert response.status_code == 302 # redirect to login
361
+
362
+
363
+# --- Git Mirror Run View ---
364
+
365
+
366
+@pytest.mark.django_db
367
+class TestGitMirrorRunView:
368
+ def test_run_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
369
+ mock_reader = _make_reader_mock()
370
+ mock_task = MagicMock()
371
+ with _disk_patch, patch("fossil.views.FossilReader", mock_reader), patch("fossil.tasks.run_git_sync", mock_task):
372
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
373
+ assert response.status_code == 302
374
+ mock_task.delay.assert_called_once_with(mirror.pk)
375
+
376
+ def test_run_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
377
+ response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
378
+ assert response.status_code == 403
379
+
380
+ def test_run_denied_for_anon(self, client, sample_project, mirror):
381
+ response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
382
+ assert response.status_code == 302 # redirect to login
--- a/tests/test_git_mirrors.py
+++ b/tests/test_git_mirrors.py
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_git_mirrors.py
+++ b/tests/test_git_mirrors.py
@@ -0,0 +1,382 @@
1 """Tests for Git mirror multi-remote sync UI views."""
2
3 from unittest.mock import MagicMock, patch
4
5 import pytest
6 from django.contrib.auth.models import User
7 from django.test import Client
8
9 from fossil.models import FossilRepository
10 from fossil.sync_models import GitMirror
11 from organization.models import Team
12 from projects.models import ProjectTeam
13
14 # Reusable patch that makes FossilRepository.exists_on_disk return True
15 _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
16
17
18 def _make_reader_mock(**methods):
19 """Create a MagicMock that replaces FossilReader as a class."""
20 mock_cls = MagicMock()
21 instance = MagicMock()
22 mock_cls.return_value = instance
23 instance.__enter__ = MagicMock(return_value=instance)
24 instance.__exit__ = MagicMock(return_value=False)
25 for name, val in methods.items():
26 getattr(instance, name).return_value = val
27 return mock_cls
28
29
30 @pytest.fixture
31 def fossil_repo_obj(sample_project):
32 """Return the auto-created FossilRepository for sample_project."""
33 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
34
35
36 @pytest.fixture
37 def mirror(fossil_repo_obj, admin_user):
38 return GitMirror.objects.create(
39 repository=fossil_repo_obj,
40 git_remote_url="https://github.com/org/repo.git",
41 auth_method="token",
42 auth_credential="ghp_test123",
43 sync_direction="push",
44 sync_mode="scheduled",
45 sync_schedule="*/15 * * * *",
46 git_branch="main",
47 fossil_branch="trunk",
48 created_by=admin_user,
49 )
50
51
52 @pytest.fixture
53 def second_mirror(fossil_repo_obj, admin_user):
54 return GitMirror.objects.create(
55 repository=fossil_repo_obj,
56 git_remote_url="https://gitlab.com/org/repo.git",
57 auth_method="oauth_gitlab",
58 sync_direction="both",
59 sync_mode="both",
60 sync_schedule="0 */6 * * *",
61 git_branch="main",
62 fossil_branch="trunk",
63 created_by=admin_user,
64 )
65
66
67 @pytest.fixture
68 def writer_user(db, admin_user, sample_project):
69 """User with write access but not admin."""
70 writer = User.objects.create_user(username="mirror_writer", password="testpass123")
71 team = Team.objects.create(name="Mirror Writers", organization=sample_project.organization, created_by=admin_user)
72 team.members.add(writer)
73 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
74 return writer
75
76
77 @pytest.fixture
78 def writer_client(writer_user):
79 client = Client()
80 client.login(username="mirror_writer", password="testpass123")
81 return client
82
83
84 # --- GitMirror Model Tests ---
85
86
87 @pytest.mark.django_db
88 class TestGitMirrorModel:
89 def test_create_mirror(self, mirror):
90 assert mirror.pk is not None
91 assert mirror.git_remote_url == "https://github.com/org/repo.git"
92 assert mirror.sync_direction == "push"
93
94 def test_str_representation(self, mirror):
95 assert "github.com/org/repo.git" in str(mirror)
96
97 def test_soft_delete(self, mirror, admin_user):
98 mirror.soft_delete(user=admin_user)
99 assert mirror.is_deleted
100 assert GitMirror.objects.filter(pk=mirror.pk).count() == 0
101 assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1
102
103 def test_multiple_mirrors_per_repo(self, mirror, second_mirror, fossil_repo_obj):
104 mirrors = GitMirror.objects.filter(repository=fossil_repo_obj)
105 assert mirrors.count() == 2
106
107 def test_ordering(self, mirror, second_mirror):
108 """Mirrors are ordered newest first."""
109 mirrors = list(GitMirror.objects.all())
110 assert mirrors[0] == second_mirror
111 assert mirrors[1] == mirror
112
113
114 # --- Sync Page (sync.html) showing mirrors ---
115
116
117 @pytest.mark.django_db
118 class TestSyncPageMirrorListing:
119 def test_sync_page_shows_mirrors(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror):
120 mock = _make_reader_mock()
121 with _disk_patch, patch("fossil.views.FossilReader", mock):
122 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
123 assert response.status_code == 200
124 content = response.content.decode()
125 assert "github.com/org/repo.git" in content
126 assert "gitlab.com/org/repo.git" in content
127 assert "Git Mirrors" in content
128
129 def test_sync_page_shows_empty_state(self, admin_client, sample_project, fossil_repo_obj):
130 mock = _make_reader_mock()
131 with _disk_patch, patch("fossil.views.FossilReader", mock):
132 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
133 assert response.status_code == 200
134 content = response.content.decode()
135 assert "No Git mirrors configured" in content
136
137 def test_sync_page_shows_add_mirror_button(self, admin_client, sample_project, fossil_repo_obj):
138 mock = _make_reader_mock()
139 with _disk_patch, patch("fossil.views.FossilReader", mock):
140 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
141 assert response.status_code == 200
142 assert "Add Mirror" in response.content.decode()
143
144 def test_sync_page_shows_mirror_direction_badges(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror):
145 mock = _make_reader_mock()
146 with _disk_patch, patch("fossil.views.FossilReader", mock):
147 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
148 content = response.content.decode()
149 # Check direction labels rendered
150 assert "Push" in content
151 assert "Bidirectional" in content
152
153 def test_sync_page_shows_edit_delete_links(self, admin_client, sample_project, fossil_repo_obj, mirror):
154 mock = _make_reader_mock()
155 with _disk_patch, patch("fossil.views.FossilReader", mock):
156 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
157 content = response.content.decode()
158 assert "Edit" in content
159 assert "Delete" in content
160 assert "Run Now" in content
161
162 def test_sync_page_denied_for_anon(self, client, sample_project):
163 response = client.get(f"/projects/{sample_project.slug}/fossil/sync/")
164 assert response.status_code == 302 # redirect to login
165
166
167 # --- Git Mirror Config View (Add) ---
168
169
170 @pytest.mark.django_db
171 class TestGitMirrorAddView:
172 def test_get_add_form(self, admin_client, sample_project, fossil_repo_obj):
173 mock = _make_reader_mock()
174 with _disk_patch, patch("fossil.views.FossilReader", mock):
175 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/")
176 assert response.status_code == 200
177 content = response.content.decode()
178 assert "Add Git Mirror" in content
179 assert "Quick Connect" in content
180
181 def test_create_mirror(self, admin_client, sample_project, fossil_repo_obj):
182 mock = _make_reader_mock()
183 with _disk_patch, patch("fossil.views.FossilReader", mock):
184 response = admin_client.post(
185 f"/projects/{sample_project.slug}/fossil/sync/git/",
186 {
187 "action": "create",
188 "git_remote_url": "https://github.com/test/new-repo.git",
189 "auth_method": "token",
190 "auth_credential": "ghp_newtoken",
191 "sync_direction": "push",
192 "sync_mode": "scheduled",
193 "sync_schedule": "*/30 * * * *",
194 "git_branch": "main",
195 "fossil_branch": "trunk",
196 },
197 )
198 assert response.status_code == 302
199 mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/new-repo.git")
200 assert mirror.sync_direction == "push"
201 assert mirror.sync_schedule == "*/30 * * * *"
202 assert mirror.fossil_branch == "trunk"
203
204 def test_create_mirror_with_sync_tickets(self, admin_client, sample_project, fossil_repo_obj):
205 mock = _make_reader_mock()
206 with _disk_patch, patch("fossil.views.FossilReader", mock):
207 response = admin_client.post(
208 f"/projects/{sample_project.slug}/fossil/sync/git/",
209 {
210 "action": "create",
211 "git_remote_url": "https://github.com/test/tickets-repo.git",
212 "auth_method": "token",
213 "sync_direction": "push",
214 "sync_mode": "scheduled",
215 "sync_schedule": "*/15 * * * *",
216 "git_branch": "main",
217 "fossil_branch": "trunk",
218 "sync_tickets": "on",
219 "sync_wiki": "on",
220 },
221 )
222 assert response.status_code == 302
223 mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/tickets-repo.git")
224 assert mirror.sync_tickets is True
225 assert mirror.sync_wiki is True
226
227 def test_create_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
228 response = writer_client.post(
229 f"/projects/{sample_project.slug}/fossil/sync/git/",
230 {"action": "create", "git_remote_url": "https://evil.com/repo.git"},
231 )
232 assert response.status_code == 403
233
234 def test_create_denied_for_anon(self, client, sample_project):
235 response = client.post(
236 f"/projects/{sample_project.slug}/fossil/sync/git/",
237 {"action": "create", "git_remote_url": "https://example.com/repo.git"},
238 )
239 assert response.status_code == 302 # redirect to login
240
241
242 # --- Git Mirror Config View (Edit) ---
243
244
245 @pytest.mark.django_db
246 class TestGitMirrorEditView:
247 def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj, mirror):
248 mock = _make_reader_mock()
249 with _disk_patch, patch("fossil.views.FossilReader", mock):
250 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/")
251 assert response.status_code == 200
252 content = response.content.decode()
253 assert "Edit Git Mirror" in content
254 assert "github.com/org/repo.git" in content
255 # Should NOT show quick connect section when editing
256 assert "Quick Connect" not in content
257
258 def test_edit_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
259 mock = _make_reader_mock()
260 with _disk_patch, patch("fossil.views.FossilReader", mock):
261 response = admin_client.post(
262 f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
263 {
264 "action": "update",
265 "git_remote_url": "https://github.com/org/updated-repo.git",
266 "auth_method": "ssh",
267 "sync_direction": "both",
268 "sync_mode": "both",
269 "sync_schedule": "0 */2 * * *",
270 "git_branch": "develop",
271 "fossil_branch": "trunk",
272 },
273 )
274 assert response.status_code == 302
275 mirror.refresh_from_db()
276 assert mirror.git_remote_url == "https://github.com/org/updated-repo.git"
277 assert mirror.auth_method == "ssh"
278 assert mirror.sync_direction == "both"
279 assert mirror.sync_schedule == "0 */2 * * *"
280 assert mirror.git_branch == "develop"
281
282 def test_edit_preserves_credential_when_blank(self, admin_client, sample_project, fossil_repo_obj, mirror):
283 """Editing without providing a new credential should keep the old one."""
284 old_credential = mirror.auth_credential
285 mock = _make_reader_mock()
286 with _disk_patch, patch("fossil.views.FossilReader", mock):
287 response = admin_client.post(
288 f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
289 {
290 "action": "update",
291 "git_remote_url": "https://github.com/org/repo.git",
292 "auth_method": "token",
293 "auth_credential": "",
294 "sync_direction": "push",
295 "sync_mode": "scheduled",
296 "sync_schedule": "*/15 * * * *",
297 "git_branch": "main",
298 "fossil_branch": "trunk",
299 },
300 )
301 assert response.status_code == 302
302 mirror.refresh_from_db()
303 assert mirror.auth_credential == old_credential
304
305 def test_edit_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj):
306 mock = _make_reader_mock()
307 with _disk_patch, patch("fossil.views.FossilReader", mock):
308 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/99999/edit/")
309 assert response.status_code == 404
310
311 def test_edit_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
312 response = writer_client.post(
313 f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
314 {"action": "update", "git_remote_url": "https://evil.com/repo.git"},
315 )
316 assert response.status_code == 403
317
318
319 # --- Git Mirror Delete View ---
320
321
322 @pytest.mark.django_db
323 class TestGitMirrorDeleteView:
324 def test_get_delete_confirmation(self, admin_client, sample_project, fossil_repo_obj, mirror):
325 mock = _make_reader_mock()
326 with _disk_patch, patch("fossil.views.FossilReader", mock):
327 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
328 assert response.status_code == 200
329 content = response.content.decode()
330 assert "Delete Git Mirror" in content
331 assert "github.com/org/repo.git" in content
332
333 def test_delete_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
334 mock = _make_reader_mock()
335 with _disk_patch, patch("fossil.views.FossilReader", mock):
336 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
337 assert response.status_code == 302
338 mirror.refresh_from_db()
339 assert mirror.is_deleted
340
341 def test_delete_removes_from_active_queries(self, admin_client, sample_project, fossil_repo_obj, mirror):
342 mock = _make_reader_mock()
343 with _disk_patch, patch("fossil.views.FossilReader", mock):
344 admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
345 assert GitMirror.objects.filter(pk=mirror.pk).count() == 0
346 assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1
347
348 def test_delete_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj):
349 mock = _make_reader_mock()
350 with _disk_patch, patch("fossil.views.FossilReader", mock):
351 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/99999/delete/")
352 assert response.status_code == 404
353
354 def test_delete_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
355 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
356 assert response.status_code == 403
357
358 def test_delete_denied_for_anon(self, client, sample_project, mirror):
359 response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
360 assert response.status_code == 302 # redirect to login
361
362
363 # --- Git Mirror Run View ---
364
365
366 @pytest.mark.django_db
367 class TestGitMirrorRunView:
368 def test_run_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
369 mock_reader = _make_reader_mock()
370 mock_task = MagicMock()
371 with _disk_patch, patch("fossil.views.FossilReader", mock_reader), patch("fossil.tasks.run_git_sync", mock_task):
372 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
373 assert response.status_code == 302
374 mock_task.delay.assert_called_once_with(mirror.pk)
375
376 def test_run_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
377 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
378 assert response.status_code == 403
379
380 def test_run_denied_for_anon(self, client, sample_project, mirror):
381 response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
382 assert response.status_code == 302 # redirect to login

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button