partweave 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/chunk-2IGBGILB.js +1230 -0
  4. package/dist/chunk-2IGBGILB.js.map +1 -0
  5. package/dist/create-bin.js +17 -0
  6. package/dist/create-bin.js.map +1 -0
  7. package/dist/index.js +164 -0
  8. package/dist/index.js.map +1 -0
  9. package/modules/_core/api-client/package.json +22 -0
  10. package/modules/_core/api-client/src/index.ts +32 -0
  11. package/modules/_core/api-client/src/schema.d.ts +12 -0
  12. package/modules/_core/api-client/tsconfig.json +7 -0
  13. package/modules/_core/mobile/app/_layout.tsx +10 -0
  14. package/modules/_core/mobile/app/index.tsx +24 -0
  15. package/modules/_core/mobile/app.json +18 -0
  16. package/modules/_core/mobile/babel.config.js +6 -0
  17. package/modules/_core/mobile/expo-env.d.ts +3 -0
  18. package/modules/_core/mobile/jest.config.js +10 -0
  19. package/modules/_core/mobile/metro.config.js +18 -0
  20. package/modules/_core/mobile/package.json +36 -0
  21. package/modules/_core/mobile/src/nav.ts +9 -0
  22. package/modules/_core/mobile/src/providers.tsx +17 -0
  23. package/modules/_core/mobile/tsconfig.json +10 -0
  24. package/modules/_core/root/.editorconfig +15 -0
  25. package/modules/_core/root/_gitignore +29 -0
  26. package/modules/_core/server/.python-version +1 -0
  27. package/modules/_core/server/config/__init__.py +0 -0
  28. package/modules/_core/server/config/asgi.py +7 -0
  29. package/modules/_core/server/config/settings.py +127 -0
  30. package/modules/_core/server/config/urls.py +17 -0
  31. package/modules/_core/server/config/wsgi.py +7 -0
  32. package/modules/_core/server/manage.py +21 -0
  33. package/modules/_core/server/pyproject.toml +38 -0
  34. package/modules/_core/server/tests/test_health.py +4 -0
  35. package/modules/_core/shared/package.json +17 -0
  36. package/modules/_core/shared/src/index.ts +11 -0
  37. package/modules/_core/shared/tsconfig.json +7 -0
  38. package/modules/_core/web/app/globals.css +3 -0
  39. package/modules/_core/web/app/layout.tsx +19 -0
  40. package/modules/_core/web/app/page.tsx +21 -0
  41. package/modules/_core/web/app/providers.tsx +19 -0
  42. package/modules/_core/web/next-env.d.ts +5 -0
  43. package/modules/_core/web/next.config.mjs +9 -0
  44. package/modules/_core/web/package.json +31 -0
  45. package/modules/_core/web/postcss.config.mjs +6 -0
  46. package/modules/_core/web/src/nav.ts +9 -0
  47. package/modules/_core/web/tailwind.config.ts +9 -0
  48. package/modules/_core/web/tsconfig.json +18 -0
  49. package/modules/_core/web/vitest.config.mts +19 -0
  50. package/modules/auth/mobile/app/login.tsx +63 -0
  51. package/modules/auth/mobile/src/auth/auth-context.tsx +61 -0
  52. package/modules/auth/mobile/src/auth/client.test.ts +69 -0
  53. package/modules/auth/mobile/src/auth/client.ts +59 -0
  54. package/modules/auth/mobile/src/lib/config.ts +22 -0
  55. package/modules/auth/mobile/src/lib/token-store.ts +20 -0
  56. package/modules/auth/module.json +47 -0
  57. package/modules/auth/server/accounts/__init__.py +0 -0
  58. package/modules/auth/server/accounts/admin.py +4 -0
  59. package/modules/auth/server/accounts/apps.py +6 -0
  60. package/modules/auth/server/accounts/migrations/0001_initial.py +38 -0
  61. package/modules/auth/server/accounts/migrations/__init__.py +0 -0
  62. package/modules/auth/server/accounts/models.py +39 -0
  63. package/modules/auth/server/accounts/serializers.py +21 -0
  64. package/modules/auth/server/accounts/urls.py +11 -0
  65. package/modules/auth/server/accounts/views.py +26 -0
  66. package/modules/auth/server/tests/test_auth.py +44 -0
  67. package/modules/auth/shared/src/auth.ts +27 -0
  68. package/modules/auth/web/app/login/page.tsx +61 -0
  69. package/modules/auth/web/src/auth/auth-context.test.tsx +66 -0
  70. package/modules/auth/web/src/auth/auth-context.tsx +63 -0
  71. package/modules/auth/web/src/auth/client.test.ts +73 -0
  72. package/modules/auth/web/src/auth/client.ts +59 -0
  73. package/modules/auth/web/src/lib/config.ts +2 -0
  74. package/modules/auth/web/src/lib/token-store.ts +23 -0
  75. package/modules/ci/module.json +11 -0
  76. package/modules/db-postgres/module.json +22 -0
  77. package/modules/docker/module.json +19 -0
  78. package/modules/docker/root/infra/docker-compose.yml +20 -0
  79. package/modules/docker/server/.dockerignore +7 -0
  80. package/modules/example/mobile/app/profile.test.tsx +44 -0
  81. package/modules/example/mobile/app/profile.tsx +39 -0
  82. package/modules/example/module.json +21 -0
  83. package/modules/example/web/app/profile/page.test.tsx +41 -0
  84. package/modules/example/web/app/profile/page.tsx +34 -0
  85. package/modules/storage/module.json +34 -0
  86. package/modules/storage/server/storage/__init__.py +0 -0
  87. package/modules/storage/server/storage/base.py +24 -0
  88. package/modules/storage/server/storage/factory.py +20 -0
  89. package/modules/storage/server/storage/local.py +28 -0
  90. package/modules/storage/server/storage/s3.py +30 -0
  91. package/modules/storage/server/tests/test_storage.py +20 -0
  92. package/package.json +69 -0
@@ -0,0 +1,61 @@
1
+ import { createContext, useContext, useEffect, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import type { AuthUser, Credentials } from "@app/shared";
4
+ import * as auth from "@/auth/client";
5
+
6
+ interface AuthState {
7
+ user: AuthUser | null;
8
+ loading: boolean;
9
+ login: (creds: Credentials) => Promise<void>;
10
+ register: (creds: Credentials) => Promise<void>;
11
+ logout: () => Promise<void>;
12
+ }
13
+
14
+ const AuthCtx = createContext<AuthState | null>(null);
15
+
16
+ export function AuthProvider({ children }: { children: ReactNode }) {
17
+ const [user, setUser] = useState<AuthUser | null>(null);
18
+ const [loading, setLoading] = useState(true);
19
+
20
+ async function load() {
21
+ try {
22
+ setUser(await auth.fetchMe());
23
+ } catch (err) {
24
+ // A 401 means the stored token is stale/expired — clear it so it isn't
25
+ // re-sent on later requests. Other errors (e.g. server down) keep it.
26
+ if (err instanceof auth.ApiError && err.status === 401) await auth.logout();
27
+ setUser(null);
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ }
32
+
33
+ useEffect(() => {
34
+ void load();
35
+ }, []);
36
+
37
+ const value: AuthState = {
38
+ user,
39
+ loading,
40
+ login: async (creds) => {
41
+ await auth.login(creds);
42
+ await load();
43
+ },
44
+ register: async (creds) => {
45
+ await auth.register(creds);
46
+ await load();
47
+ },
48
+ logout: async () => {
49
+ await auth.logout();
50
+ setUser(null);
51
+ },
52
+ };
53
+
54
+ return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
55
+ }
56
+
57
+ export function useAuth(): AuthState {
58
+ const ctx = useContext(AuthCtx);
59
+ if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
60
+ return ctx;
61
+ }
@@ -0,0 +1,69 @@
1
+ /** @jest-environment node */
2
+ let mockToken: string | null = null;
3
+
4
+ jest.mock("@/lib/config", () => ({ API_URL: "http://api.test" }));
5
+ jest.mock("@/lib/token-store", () => ({
6
+ tokenStore: {
7
+ get: jest.fn(async () => mockToken),
8
+ set: jest.fn(async () => {}),
9
+ clear: jest.fn(async () => {}),
10
+ },
11
+ }));
12
+
13
+ import { register, login, fetchMe, logout, ApiError } from "./client";
14
+ import { tokenStore } from "@/lib/token-store";
15
+
16
+ // A minimal Response-shaped object so the test doesn't depend on a global
17
+ // Response being present in the test environment.
18
+ function fakeRes(body: unknown, status = 200) {
19
+ return {
20
+ ok: status >= 200 && status < 300,
21
+ status,
22
+ json: async () => body,
23
+ text: async () => JSON.stringify(body),
24
+ };
25
+ }
26
+ function callTo(path: string): unknown[] | undefined {
27
+ return (global.fetch as jest.Mock).mock.calls.find((c) => String(c[0]).endsWith(path));
28
+ }
29
+ function authHeaderOf(call: unknown[] | undefined): unknown {
30
+ const init = call?.[1] as { headers?: Record<string, unknown> } | undefined;
31
+ return init?.headers?.Authorization;
32
+ }
33
+
34
+ describe("mobile auth client", () => {
35
+ beforeEach(() => {
36
+ mockToken = "STALE.TOKEN"; // a leftover token is present
37
+ global.fetch = jest.fn(async () => fakeRes({ access: "a", refresh: "r" })) as unknown as typeof fetch;
38
+ });
39
+
40
+ it("register sends NO Authorization even with a stored token (regression: 401 on register)", async () => {
41
+ await register({ email: "a@b.com", password: "supersecret" });
42
+ const call = callTo("/api/auth/register");
43
+ expect(call).toBeTruthy();
44
+ expect(authHeaderOf(call)).toBeUndefined();
45
+ });
46
+
47
+ it("login sends NO Authorization", async () => {
48
+ await login({ email: "a@b.com", password: "supersecret" });
49
+ expect(authHeaderOf(callTo("/api/auth/token"))).toBeUndefined();
50
+ });
51
+
52
+ it("fetchMe DOES send the stored token", async () => {
53
+ (global.fetch as jest.Mock).mockResolvedValueOnce(fakeRes({ id: 1, email: "a@b.com" }));
54
+ await fetchMe();
55
+ expect(authHeaderOf(callTo("/api/auth/me"))).toBe("Bearer STALE.TOKEN");
56
+ });
57
+
58
+ it("throws ApiError carrying the HTTP status on failure", async () => {
59
+ (global.fetch as jest.Mock).mockResolvedValueOnce(fakeRes({ detail: "no" }, 401));
60
+ await expect(fetchMe()).rejects.toBeInstanceOf(ApiError);
61
+ (global.fetch as jest.Mock).mockResolvedValueOnce(fakeRes({ detail: "no" }, 401));
62
+ await expect(fetchMe()).rejects.toMatchObject({ status: 401 });
63
+ });
64
+
65
+ it("logout clears the token store", async () => {
66
+ await logout();
67
+ expect(tokenStore.clear).toHaveBeenCalled();
68
+ });
69
+ });
@@ -0,0 +1,59 @@
1
+ import type { AuthTokens, AuthUser, Credentials } from "@app/shared";
2
+ import { API_URL } from "@/lib/config";
3
+ import { tokenStore } from "@/lib/token-store";
4
+
5
+ /** Error carrying the HTTP status, so callers can react to e.g. a 401. */
6
+ export class ApiError extends Error {
7
+ constructor(
8
+ public status: number,
9
+ body: string,
10
+ ) {
11
+ super(`${status} ${body}`);
12
+ this.name = "ApiError";
13
+ }
14
+ }
15
+
16
+ // `auth` defaults to true — the stored token is attached. Public endpoints
17
+ // (register/login/refresh) MUST pass `auth: false`: sending a stale/expired
18
+ // token to them makes the server's JWTAuthentication reject the request with a
19
+ // 401 before it ever reaches the AllowAny permission.
20
+ async function req<T>(path: string, init: RequestInit & { auth?: boolean } = {}): Promise<T> {
21
+ const { auth = true, headers, ...rest } = init;
22
+ const token = auth ? await tokenStore.get() : null;
23
+ const res = await fetch(`${API_URL}${path}`, {
24
+ ...rest,
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
28
+ ...(headers ?? {}),
29
+ },
30
+ });
31
+ if (!res.ok) throw new ApiError(res.status, await res.text());
32
+ return (res.status === 204 ? null : await res.json()) as T;
33
+ }
34
+
35
+ export async function login(creds: Credentials): Promise<void> {
36
+ const tokens = await req<AuthTokens>("/api/auth/token", {
37
+ method: "POST",
38
+ body: JSON.stringify(creds),
39
+ auth: false,
40
+ });
41
+ await tokenStore.set(tokens);
42
+ }
43
+
44
+ export async function register(creds: Credentials): Promise<void> {
45
+ await req("/api/auth/register", {
46
+ method: "POST",
47
+ body: JSON.stringify(creds),
48
+ auth: false,
49
+ });
50
+ await login(creds);
51
+ }
52
+
53
+ export async function fetchMe(): Promise<AuthUser> {
54
+ return req<AuthUser>("/api/auth/me");
55
+ }
56
+
57
+ export async function logout(): Promise<void> {
58
+ await tokenStore.clear();
59
+ }
@@ -0,0 +1,22 @@
1
+ import Constants from "expo-constants";
2
+
3
+ /**
4
+ * A physical device (Expo Go) can't reach the server at "localhost" — that's the
5
+ * phone itself. In dev we derive the dev machine's LAN IP from Metro's host URI
6
+ * (e.g. "192.168.1.78:8081") and point at its :8000.
7
+ *
8
+ * Set EXPO_PUBLIC_API_URL to override (required for production / device builds
9
+ * and simulators on a different network).
10
+ */
11
+ function devServerUrl(): string | undefined {
12
+ const c = Constants as unknown as {
13
+ expoConfig?: { hostUri?: string } | null;
14
+ expoGoConfig?: { debuggerHost?: string } | null;
15
+ };
16
+ const hostUri = c.expoConfig?.hostUri ?? c.expoGoConfig?.debuggerHost;
17
+ const host = hostUri?.split(":")[0];
18
+ return host ? `http://${host}:8000` : undefined;
19
+ }
20
+
21
+ export const API_URL =
22
+ process.env.EXPO_PUBLIC_API_URL ?? devServerUrl() ?? "http://localhost:8000";
@@ -0,0 +1,20 @@
1
+ import * as SecureStore from "expo-secure-store";
2
+ import type { AuthTokens, TokenStore } from "@app/shared";
3
+
4
+ const ACCESS = "access_token";
5
+ const REFRESH = "refresh_token";
6
+
7
+ /** Mobile TokenStore over the device secure enclave (same interface as web). */
8
+ export const tokenStore: TokenStore = {
9
+ async get() {
10
+ return SecureStore.getItemAsync(ACCESS);
11
+ },
12
+ async set(tokens: AuthTokens) {
13
+ await SecureStore.setItemAsync(ACCESS, tokens.access);
14
+ await SecureStore.setItemAsync(REFRESH, tokens.refresh);
15
+ },
16
+ async clear() {
17
+ await SecureStore.deleteItemAsync(ACCESS);
18
+ await SecureStore.deleteItemAsync(REFRESH);
19
+ },
20
+ };
@@ -0,0 +1,47 @@
1
+ {
2
+ "id": "auth",
3
+ "title": "Authentication (JWT)",
4
+ "description": "Email/password auth with JWT — register, login, refresh, and a current-user endpoint.",
5
+ "kind": "feature",
6
+ "targets": ["server", "web", "mobile", "shared"],
7
+ "requiresApps": ["server"],
8
+ "requires": ["db-postgres"],
9
+ "provides": "auth",
10
+ "default": true,
11
+ "wiring": {
12
+ "server": {
13
+ "installedApps": ["\"accounts\","],
14
+ "urls": ["path(\"api/auth/\", include(\"accounts.urls\")),"],
15
+ "settings": ["AUTH_USER_MODEL = \"accounts.User\""],
16
+ "deps": ["djangorestframework-simplejwt>=5.3"],
17
+ "anchors": {
18
+ "drf-auth": ["\"rest_framework_simplejwt.authentication.JWTAuthentication\","]
19
+ }
20
+ },
21
+ "shared": {
22
+ "anchors": {
23
+ "exports": ["export * from \"./auth\";"]
24
+ }
25
+ },
26
+ "web": {
27
+ "providers": ["AuthProvider,"],
28
+ "routes": ["{ href: \"/login\", label: \"Log in\" },"],
29
+ "deps": ["@app/shared@workspace:*"],
30
+ "anchors": {
31
+ "providers-import": ["import { AuthProvider } from \"@/auth/auth-context\";"]
32
+ }
33
+ },
34
+ "mobile": {
35
+ "providers": ["AuthProvider,"],
36
+ "routes": ["{ href: \"/login\", label: \"Log in\" },"],
37
+ "deps": ["@app/shared@workspace:*", "expo-secure-store@~15.0.8"],
38
+ "anchors": {
39
+ "providers-import": ["import { AuthProvider } from \"@/auth/auth-context\";"]
40
+ }
41
+ }
42
+ },
43
+ "notes": [
44
+ "Auth endpoints: POST /api/auth/register, POST /api/auth/token, POST /api/auth/token/refresh, GET /api/auth/me",
45
+ "Web stores tokens in localStorage (swap for httpOnly cookies in production); mobile uses expo-secure-store."
46
+ ]
47
+ }
File without changes
@@ -0,0 +1,4 @@
1
+ from django.contrib import admin
2
+ from django.contrib.auth import get_user_model
3
+
4
+ admin.site.register(get_user_model())
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AccountsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "accounts"
@@ -0,0 +1,38 @@
1
+ # Generated by Django 5.2.16 on 2026-07-08 09:52
2
+
3
+ import django.utils.timezone
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ('auth', '0012_alter_user_first_name_max_length'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='User',
18
+ fields=[
19
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+ ('password', models.CharField(max_length=128, verbose_name='password')),
21
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
22
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
23
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
24
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
25
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
26
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
27
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
28
+ ('email', models.EmailField(max_length=254, unique=True)),
29
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
30
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
31
+ ],
32
+ options={
33
+ 'verbose_name': 'user',
34
+ 'verbose_name_plural': 'users',
35
+ 'abstract': False,
36
+ },
37
+ ),
38
+ ]
@@ -0,0 +1,39 @@
1
+ from django.contrib.auth.models import AbstractUser, BaseUserManager
2
+ from django.db import models
3
+
4
+
5
+ class UserManager(BaseUserManager):
6
+ """User manager keyed on email instead of username."""
7
+
8
+ def _create_user(self, email: str, password: str | None, **extra):
9
+ if not email:
10
+ raise ValueError("Users must have an email address")
11
+ user = self.model(email=self.normalize_email(email), **extra)
12
+ user.set_password(password)
13
+ user.save(using=self._db)
14
+ return user
15
+
16
+ def create_user(self, email: str, password: str | None = None, **extra):
17
+ extra.setdefault("is_staff", False)
18
+ extra.setdefault("is_superuser", False)
19
+ return self._create_user(email, password, **extra)
20
+
21
+ def create_superuser(self, email: str, password: str | None = None, **extra):
22
+ extra.setdefault("is_staff", True)
23
+ extra.setdefault("is_superuser", True)
24
+ return self._create_user(email, password, **extra)
25
+
26
+
27
+ class User(AbstractUser):
28
+ """Custom user: log in with email, no username."""
29
+
30
+ username = None # type: ignore[assignment]
31
+ email = models.EmailField(unique=True)
32
+
33
+ USERNAME_FIELD = "email"
34
+ REQUIRED_FIELDS: list[str] = []
35
+
36
+ objects = UserManager()
37
+
38
+ def __str__(self) -> str:
39
+ return self.email
@@ -0,0 +1,21 @@
1
+ from django.contrib.auth import get_user_model
2
+ from rest_framework import serializers
3
+
4
+ User = get_user_model()
5
+
6
+
7
+ class UserSerializer(serializers.ModelSerializer):
8
+ class Meta:
9
+ model = User
10
+ fields = ["id", "email"]
11
+
12
+
13
+ class RegisterSerializer(serializers.ModelSerializer):
14
+ password = serializers.CharField(write_only=True, min_length=8)
15
+
16
+ class Meta:
17
+ model = User
18
+ fields = ["id", "email", "password"]
19
+
20
+ def create(self, validated_data):
21
+ return User.objects.create_user(**validated_data)
@@ -0,0 +1,11 @@
1
+ from django.urls import path
2
+ from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
3
+
4
+ from .views import MeView, RegisterView
5
+
6
+ urlpatterns = [
7
+ path("register", RegisterView.as_view(), name="register"),
8
+ path("token", TokenObtainPairView.as_view(), name="token"),
9
+ path("token/refresh", TokenRefreshView.as_view(), name="token_refresh"),
10
+ path("me", MeView.as_view(), name="me"),
11
+ ]
@@ -0,0 +1,26 @@
1
+ from django.contrib.auth import get_user_model
2
+ from rest_framework import generics, permissions
3
+ from rest_framework.request import Request
4
+ from rest_framework.response import Response
5
+ from rest_framework.views import APIView
6
+
7
+ from .serializers import RegisterSerializer, UserSerializer
8
+
9
+ User = get_user_model()
10
+
11
+
12
+ class RegisterView(generics.CreateAPIView):
13
+ queryset = User.objects.all()
14
+ serializer_class = RegisterSerializer
15
+ permission_classes = [permissions.AllowAny]
16
+ # Registration is public: don't attempt JWT authentication at all. Otherwise a
17
+ # stale/expired token sent by a client would make authentication fail with 401
18
+ # before AllowAny is ever checked, blocking registration.
19
+ authentication_classes: list = []
20
+
21
+
22
+ class MeView(APIView):
23
+ permission_classes = [permissions.IsAuthenticated]
24
+
25
+ def get(self, request: Request) -> Response:
26
+ return Response(UserSerializer(request.user).data)
@@ -0,0 +1,44 @@
1
+ import pytest
2
+
3
+ CREDS = {"email": "a@b.com", "password": "supersecret"}
4
+
5
+
6
+ @pytest.mark.django_db
7
+ def test_register_login_me():
8
+ from django.test import Client
9
+
10
+ client = Client()
11
+
12
+ r = client.post("/api/auth/register", CREDS, content_type="application/json")
13
+ assert r.status_code == 201, r.content
14
+
15
+ r = client.post("/api/auth/token", CREDS, content_type="application/json")
16
+ assert r.status_code == 200, r.content
17
+ access = r.json()["access"]
18
+
19
+ r = client.get("/api/auth/me", HTTP_AUTHORIZATION=f"Bearer {access}")
20
+ assert r.status_code == 200
21
+ assert r.json()["email"] == CREDS["email"]
22
+
23
+ # unauthenticated request is rejected
24
+ assert client.get("/api/auth/me").status_code == 401
25
+
26
+
27
+ @pytest.mark.django_db
28
+ def test_register_ignores_stale_token():
29
+ """A leftover/invalid token in the client must not block registration.
30
+
31
+ Regression test: register is public, so authentication must not run on it —
32
+ otherwise a stale Authorization header 401s before AllowAny is checked.
33
+ """
34
+ from django.test import Client
35
+
36
+ client = Client()
37
+ creds = {"email": "c@d.com", "password": "supersecret"}
38
+ r = client.post(
39
+ "/api/auth/register",
40
+ creds,
41
+ content_type="application/json",
42
+ HTTP_AUTHORIZATION="Bearer this.is.a.stale.garbage.token",
43
+ )
44
+ assert r.status_code == 201, r.content
@@ -0,0 +1,27 @@
1
+ /** Auth contracts shared by web and mobile. Concrete impls live per-platform. */
2
+
3
+ export interface Credentials {
4
+ email: string;
5
+ password: string;
6
+ }
7
+
8
+ export interface AuthTokens {
9
+ access: string;
10
+ refresh: string;
11
+ }
12
+
13
+ export interface AuthUser {
14
+ id: number;
15
+ email: string;
16
+ }
17
+
18
+ /**
19
+ * Where access/refresh tokens are persisted. Web implements this over
20
+ * localStorage/cookies; mobile over expo-secure-store. Consumers depend on this
21
+ * interface, never on a concrete store.
22
+ */
23
+ export interface TokenStore {
24
+ get(): Promise<string | null>;
25
+ set(tokens: AuthTokens): Promise<void>;
26
+ clear(): Promise<void>;
27
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { type FormEvent, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useAuth } from "@/auth/auth-context";
6
+
7
+ export default function LoginPage() {
8
+ const { login, register } = useAuth();
9
+ const router = useRouter();
10
+ const [email, setEmail] = useState("");
11
+ const [password, setPassword] = useState("");
12
+ const [mode, setMode] = useState<"login" | "register">("login");
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ async function onSubmit(e: FormEvent) {
16
+ e.preventDefault();
17
+ setError(null);
18
+ try {
19
+ await (mode === "login" ? login : register)({ email, password });
20
+ router.push("/");
21
+ } catch {
22
+ setError(mode === "login" ? "Invalid credentials" : "Registration failed");
23
+ }
24
+ }
25
+
26
+ return (
27
+ <main className="mx-auto max-w-sm p-8">
28
+ <h1 className="text-2xl font-bold">
29
+ {mode === "login" ? "Log in" : "Create account"}
30
+ </h1>
31
+ <form onSubmit={onSubmit} className="mt-6 space-y-3">
32
+ <input
33
+ className="w-full rounded border p-2"
34
+ type="email"
35
+ placeholder="Email"
36
+ value={email}
37
+ onChange={(e) => setEmail(e.target.value)}
38
+ required
39
+ />
40
+ <input
41
+ className="w-full rounded border p-2"
42
+ type="password"
43
+ placeholder="Password"
44
+ value={password}
45
+ onChange={(e) => setPassword(e.target.value)}
46
+ required
47
+ />
48
+ {error && <p className="text-sm text-red-600">{error}</p>}
49
+ <button className="w-full rounded bg-blue-600 p-2 text-white" type="submit">
50
+ {mode === "login" ? "Log in" : "Sign up"}
51
+ </button>
52
+ </form>
53
+ <button
54
+ className="mt-4 text-sm text-blue-600 underline"
55
+ onClick={() => setMode(mode === "login" ? "register" : "login")}
56
+ >
57
+ {mode === "login" ? "Need an account? Sign up" : "Have an account? Log in"}
58
+ </button>
59
+ </main>
60
+ );
61
+ }
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+
5
+ // Defined via vi.hoisted so they exist when the hoisted vi.mock() factory runs.
6
+ // A real ApiError class so the provider's `instanceof` check works.
7
+ const { ApiError, fetchMe, logout } = vi.hoisted(() => {
8
+ class ApiError extends Error {
9
+ status: number;
10
+ constructor(status: number, body = "") {
11
+ super(`${status} ${body}`);
12
+ this.name = "ApiError";
13
+ this.status = status;
14
+ }
15
+ }
16
+ return { ApiError, fetchMe: vi.fn(), logout: vi.fn(async () => {}) };
17
+ });
18
+
19
+ vi.mock("@/auth/client", () => ({
20
+ ApiError,
21
+ fetchMe,
22
+ logout,
23
+ login: vi.fn(async () => {}),
24
+ register: vi.fn(async () => {}),
25
+ }));
26
+
27
+ import { AuthProvider, useAuth } from "./auth-context";
28
+
29
+ function Probe() {
30
+ const { user, loading } = useAuth();
31
+ return <span>{loading ? "loading" : user ? user.email : "anon"}</span>;
32
+ }
33
+ const renderProvider = () =>
34
+ render(
35
+ <AuthProvider>
36
+ <Probe />
37
+ </AuthProvider>,
38
+ );
39
+
40
+ describe("web AuthProvider", () => {
41
+ beforeEach(() => {
42
+ fetchMe.mockReset();
43
+ logout.mockReset();
44
+ });
45
+
46
+ it("loads the current user on mount", async () => {
47
+ fetchMe.mockResolvedValueOnce({ id: 1, email: "a@b.com" });
48
+ renderProvider();
49
+ expect(await screen.findByText("a@b.com")).toBeTruthy();
50
+ expect(logout).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it("clears a stale token when /me returns 401", async () => {
54
+ fetchMe.mockRejectedValueOnce(new ApiError(401, "expired"));
55
+ renderProvider();
56
+ expect(await screen.findByText("anon")).toBeTruthy();
57
+ expect(logout).toHaveBeenCalledTimes(1); // stale token cleared
58
+ });
59
+
60
+ it("keeps the token on a non-401 failure (e.g. server unreachable)", async () => {
61
+ fetchMe.mockRejectedValueOnce(new Error("network down"));
62
+ renderProvider();
63
+ expect(await screen.findByText("anon")).toBeTruthy();
64
+ expect(logout).not.toHaveBeenCalled();
65
+ });
66
+ });