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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/chunk-2IGBGILB.js +1230 -0
- package/dist/chunk-2IGBGILB.js.map +1 -0
- package/dist/create-bin.js +17 -0
- package/dist/create-bin.js.map +1 -0
- package/dist/index.js +164 -0
- package/dist/index.js.map +1 -0
- package/modules/_core/api-client/package.json +22 -0
- package/modules/_core/api-client/src/index.ts +32 -0
- package/modules/_core/api-client/src/schema.d.ts +12 -0
- package/modules/_core/api-client/tsconfig.json +7 -0
- package/modules/_core/mobile/app/_layout.tsx +10 -0
- package/modules/_core/mobile/app/index.tsx +24 -0
- package/modules/_core/mobile/app.json +18 -0
- package/modules/_core/mobile/babel.config.js +6 -0
- package/modules/_core/mobile/expo-env.d.ts +3 -0
- package/modules/_core/mobile/jest.config.js +10 -0
- package/modules/_core/mobile/metro.config.js +18 -0
- package/modules/_core/mobile/package.json +36 -0
- package/modules/_core/mobile/src/nav.ts +9 -0
- package/modules/_core/mobile/src/providers.tsx +17 -0
- package/modules/_core/mobile/tsconfig.json +10 -0
- package/modules/_core/root/.editorconfig +15 -0
- package/modules/_core/root/_gitignore +29 -0
- package/modules/_core/server/.python-version +1 -0
- package/modules/_core/server/config/__init__.py +0 -0
- package/modules/_core/server/config/asgi.py +7 -0
- package/modules/_core/server/config/settings.py +127 -0
- package/modules/_core/server/config/urls.py +17 -0
- package/modules/_core/server/config/wsgi.py +7 -0
- package/modules/_core/server/manage.py +21 -0
- package/modules/_core/server/pyproject.toml +38 -0
- package/modules/_core/server/tests/test_health.py +4 -0
- package/modules/_core/shared/package.json +17 -0
- package/modules/_core/shared/src/index.ts +11 -0
- package/modules/_core/shared/tsconfig.json +7 -0
- package/modules/_core/web/app/globals.css +3 -0
- package/modules/_core/web/app/layout.tsx +19 -0
- package/modules/_core/web/app/page.tsx +21 -0
- package/modules/_core/web/app/providers.tsx +19 -0
- package/modules/_core/web/next-env.d.ts +5 -0
- package/modules/_core/web/next.config.mjs +9 -0
- package/modules/_core/web/package.json +31 -0
- package/modules/_core/web/postcss.config.mjs +6 -0
- package/modules/_core/web/src/nav.ts +9 -0
- package/modules/_core/web/tailwind.config.ts +9 -0
- package/modules/_core/web/tsconfig.json +18 -0
- package/modules/_core/web/vitest.config.mts +19 -0
- package/modules/auth/mobile/app/login.tsx +63 -0
- package/modules/auth/mobile/src/auth/auth-context.tsx +61 -0
- package/modules/auth/mobile/src/auth/client.test.ts +69 -0
- package/modules/auth/mobile/src/auth/client.ts +59 -0
- package/modules/auth/mobile/src/lib/config.ts +22 -0
- package/modules/auth/mobile/src/lib/token-store.ts +20 -0
- package/modules/auth/module.json +47 -0
- package/modules/auth/server/accounts/__init__.py +0 -0
- package/modules/auth/server/accounts/admin.py +4 -0
- package/modules/auth/server/accounts/apps.py +6 -0
- package/modules/auth/server/accounts/migrations/0001_initial.py +38 -0
- package/modules/auth/server/accounts/migrations/__init__.py +0 -0
- package/modules/auth/server/accounts/models.py +39 -0
- package/modules/auth/server/accounts/serializers.py +21 -0
- package/modules/auth/server/accounts/urls.py +11 -0
- package/modules/auth/server/accounts/views.py +26 -0
- package/modules/auth/server/tests/test_auth.py +44 -0
- package/modules/auth/shared/src/auth.ts +27 -0
- package/modules/auth/web/app/login/page.tsx +61 -0
- package/modules/auth/web/src/auth/auth-context.test.tsx +66 -0
- package/modules/auth/web/src/auth/auth-context.tsx +63 -0
- package/modules/auth/web/src/auth/client.test.ts +73 -0
- package/modules/auth/web/src/auth/client.ts +59 -0
- package/modules/auth/web/src/lib/config.ts +2 -0
- package/modules/auth/web/src/lib/token-store.ts +23 -0
- package/modules/ci/module.json +11 -0
- package/modules/db-postgres/module.json +22 -0
- package/modules/docker/module.json +19 -0
- package/modules/docker/root/infra/docker-compose.yml +20 -0
- package/modules/docker/server/.dockerignore +7 -0
- package/modules/example/mobile/app/profile.test.tsx +44 -0
- package/modules/example/mobile/app/profile.tsx +39 -0
- package/modules/example/module.json +21 -0
- package/modules/example/web/app/profile/page.test.tsx +41 -0
- package/modules/example/web/app/profile/page.tsx +34 -0
- package/modules/storage/module.json +34 -0
- package/modules/storage/server/storage/__init__.py +0 -0
- package/modules/storage/server/storage/base.py +24 -0
- package/modules/storage/server/storage/factory.py +20 -0
- package/modules/storage/server/storage/local.py +28 -0
- package/modules/storage/server/storage/s3.py +30 -0
- package/modules/storage/server/tests/test_storage.py +20 -0
- package/package.json +69 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import type { AuthUser, Credentials } from "@app/shared";
|
|
6
|
+
import * as auth from "@/auth/client";
|
|
7
|
+
|
|
8
|
+
interface AuthState {
|
|
9
|
+
user: AuthUser | null;
|
|
10
|
+
loading: boolean;
|
|
11
|
+
login: (creds: Credentials) => Promise<void>;
|
|
12
|
+
register: (creds: Credentials) => Promise<void>;
|
|
13
|
+
logout: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const AuthCtx = createContext<AuthState | null>(null);
|
|
17
|
+
|
|
18
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
19
|
+
const [user, setUser] = useState<AuthUser | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
|
|
22
|
+
async function load() {
|
|
23
|
+
try {
|
|
24
|
+
setUser(await auth.fetchMe());
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// A 401 means the stored token is stale/expired — clear it so it isn't
|
|
27
|
+
// re-sent on later requests. Other errors (e.g. server down) keep it.
|
|
28
|
+
if (err instanceof auth.ApiError && err.status === 401) await auth.logout();
|
|
29
|
+
setUser(null);
|
|
30
|
+
} finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
void load();
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const value: AuthState = {
|
|
40
|
+
user,
|
|
41
|
+
loading,
|
|
42
|
+
login: async (creds) => {
|
|
43
|
+
await auth.login(creds);
|
|
44
|
+
await load();
|
|
45
|
+
},
|
|
46
|
+
register: async (creds) => {
|
|
47
|
+
await auth.register(creds);
|
|
48
|
+
await load();
|
|
49
|
+
},
|
|
50
|
+
logout: async () => {
|
|
51
|
+
await auth.logout();
|
|
52
|
+
setUser(null);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useAuth(): AuthState {
|
|
60
|
+
const ctx = useContext(AuthCtx);
|
|
61
|
+
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
|
|
62
|
+
return ctx;
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
// A leftover token is present in storage for every test — the exact condition
|
|
5
|
+
// that produced the register-401 bug.
|
|
6
|
+
let storedToken: string | null = null;
|
|
7
|
+
|
|
8
|
+
vi.mock("@/lib/config", () => ({ API_URL: "http://api.test" }));
|
|
9
|
+
vi.mock("@/lib/token-store", () => ({
|
|
10
|
+
tokenStore: {
|
|
11
|
+
get: vi.fn(async () => storedToken),
|
|
12
|
+
set: vi.fn(async () => {}),
|
|
13
|
+
clear: vi.fn(async () => {}),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { register, login, fetchMe, logout, ApiError } from "./client";
|
|
18
|
+
import { tokenStore } from "@/lib/token-store";
|
|
19
|
+
|
|
20
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
21
|
+
return new Response(JSON.stringify(body), {
|
|
22
|
+
status,
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function callTo(fetchMock: ReturnType<typeof vi.fn>, path: string) {
|
|
28
|
+
return fetchMock.mock.calls.find((c) => String(c[0]).endsWith(path));
|
|
29
|
+
}
|
|
30
|
+
function authHeaderOf(call: unknown[] | undefined): unknown {
|
|
31
|
+
const init = call?.[1] as { headers?: Record<string, unknown> } | undefined;
|
|
32
|
+
return init?.headers?.Authorization;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("web auth client", () => {
|
|
36
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
storedToken = "STALE.TOKEN";
|
|
40
|
+
fetchMock = vi.fn(async () => jsonResponse({ access: "a", refresh: "r" }));
|
|
41
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("register sends NO Authorization even with a stored token (regression: 401 on register)", async () => {
|
|
45
|
+
await register({ email: "a@b.com", password: "supersecret" });
|
|
46
|
+
const call = callTo(fetchMock, "/api/auth/register");
|
|
47
|
+
expect(call, "register endpoint was called").toBeTruthy();
|
|
48
|
+
expect(authHeaderOf(call)).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("login sends NO Authorization", async () => {
|
|
52
|
+
await login({ email: "a@b.com", password: "supersecret" });
|
|
53
|
+
expect(authHeaderOf(callTo(fetchMock, "/api/auth/token"))).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fetchMe DOES send the stored token", async () => {
|
|
57
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 1, email: "a@b.com" }));
|
|
58
|
+
await fetchMe();
|
|
59
|
+
expect(authHeaderOf(callTo(fetchMock, "/api/auth/me"))).toBe("Bearer STALE.TOKEN");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws ApiError carrying the HTTP status on failure", async () => {
|
|
63
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ detail: "no" }, 401));
|
|
64
|
+
await expect(fetchMe()).rejects.toBeInstanceOf(ApiError);
|
|
65
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ detail: "no" }, 401));
|
|
66
|
+
await expect(fetchMe()).rejects.toMatchObject({ status: 401 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("logout clears the token store", async () => {
|
|
70
|
+
await logout();
|
|
71
|
+
expect(tokenStore.clear).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -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,23 @@
|
|
|
1
|
+
import type { AuthTokens, TokenStore } from "@app/shared";
|
|
2
|
+
|
|
3
|
+
const ACCESS = "access_token";
|
|
4
|
+
const REFRESH = "refresh_token";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Web TokenStore over localStorage. For production, prefer httpOnly cookies set
|
|
8
|
+
* by a Next.js route handler — swap this impl without touching callers.
|
|
9
|
+
*/
|
|
10
|
+
export const tokenStore: TokenStore = {
|
|
11
|
+
async get() {
|
|
12
|
+
if (typeof window === "undefined") return null;
|
|
13
|
+
return window.localStorage.getItem(ACCESS);
|
|
14
|
+
},
|
|
15
|
+
async set(tokens: AuthTokens) {
|
|
16
|
+
window.localStorage.setItem(ACCESS, tokens.access);
|
|
17
|
+
window.localStorage.setItem(REFRESH, tokens.refresh);
|
|
18
|
+
},
|
|
19
|
+
async clear() {
|
|
20
|
+
window.localStorage.removeItem(ACCESS);
|
|
21
|
+
window.localStorage.removeItem(REFRESH);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ci",
|
|
3
|
+
"title": "CI workflows",
|
|
4
|
+
"description": "Per-app GitHub Actions with path filters, so each part builds independently.",
|
|
5
|
+
"kind": "feature",
|
|
6
|
+
"targets": ["root"],
|
|
7
|
+
"default": true,
|
|
8
|
+
"notes": [
|
|
9
|
+
"CI: see .github/workflows/*.yml — each app's pipeline runs only when its files change."
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "db-postgres",
|
|
3
|
+
"title": "PostgreSQL",
|
|
4
|
+
"description": "Use PostgreSQL instead of the default SQLite.",
|
|
5
|
+
"kind": "feature",
|
|
6
|
+
"targets": ["server"],
|
|
7
|
+
"requiresApps": ["server"],
|
|
8
|
+
"provides": "database",
|
|
9
|
+
"default": true,
|
|
10
|
+
"env": {
|
|
11
|
+
"DATABASE_URL": "postgres://app:app@localhost:5432/app"
|
|
12
|
+
},
|
|
13
|
+
"wiring": {
|
|
14
|
+
"server": {
|
|
15
|
+
"deps": ["psycopg[binary]>=3.2"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"notes": [
|
|
19
|
+
"PostgreSQL: set DATABASE_URL in .env (already added to .env.example).",
|
|
20
|
+
"Need a local database? Include the 'docker' component and run `make db-up`."
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "docker",
|
|
3
|
+
"title": "Docker & Compose",
|
|
4
|
+
"description": "A Postgres compose file for local dev and a production Dockerfile for the server.",
|
|
5
|
+
"kind": "feature",
|
|
6
|
+
"targets": ["server", "root"],
|
|
7
|
+
"requiresApps": ["server"],
|
|
8
|
+
"default": true,
|
|
9
|
+
"env": {
|
|
10
|
+
"POSTGRES_USER": "app",
|
|
11
|
+
"POSTGRES_PASSWORD": "app",
|
|
12
|
+
"POSTGRES_DB": "app",
|
|
13
|
+
"POSTGRES_PORT": "5432"
|
|
14
|
+
},
|
|
15
|
+
"notes": [
|
|
16
|
+
"Local DB: `make db-up` (docker compose). Server image: `docker build apps/server`.",
|
|
17
|
+
"Postgres creds/port come from .env (POSTGRES_USER/PASSWORD/DB/PORT); keep them in sync with DATABASE_URL."
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
services:
|
|
2
|
+
db:
|
|
3
|
+
image: postgres:16
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: ${POSTGRES_USER:-app}
|
|
6
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
|
|
7
|
+
POSTGRES_DB: ${POSTGRES_DB:-app}
|
|
8
|
+
ports:
|
|
9
|
+
# Bound to localhost so the dev database isn't exposed on the network.
|
|
10
|
+
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
|
11
|
+
volumes:
|
|
12
|
+
- pgdata:/var/lib/postgresql/data
|
|
13
|
+
healthcheck:
|
|
14
|
+
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app}"]
|
|
15
|
+
interval: 5s
|
|
16
|
+
timeout: 5s
|
|
17
|
+
retries: 5
|
|
18
|
+
|
|
19
|
+
volumes:
|
|
20
|
+
pgdata:
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react-native";
|
|
3
|
+
import { useAuth } from "@/auth/auth-context";
|
|
4
|
+
|
|
5
|
+
// Create the mock inside the factory (no out-of-scope refs), then import it back
|
|
6
|
+
// — jest hoists jest.mock above the imports, so `useAuth` is the mocked fn here.
|
|
7
|
+
jest.mock("@/auth/auth-context", () => ({ useAuth: jest.fn() }));
|
|
8
|
+
jest.mock("expo-router", () => {
|
|
9
|
+
const { Text } = require("react-native");
|
|
10
|
+
return { Link: ({ children }: { children: ReactNode }) => <Text>{children}</Text> };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
import Profile from "./profile";
|
|
14
|
+
|
|
15
|
+
const mockUseAuth = useAuth as jest.Mock;
|
|
16
|
+
|
|
17
|
+
describe("example Profile screen", () => {
|
|
18
|
+
beforeEach(() => mockUseAuth.mockReset());
|
|
19
|
+
|
|
20
|
+
it("shows a loading state", () => {
|
|
21
|
+
mockUseAuth.mockReturnValue({ user: null, loading: true, logout: jest.fn() });
|
|
22
|
+
render(<Profile />);
|
|
23
|
+
expect(screen.getByText("Loading…")).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("prompts to log in when there is no user", () => {
|
|
27
|
+
mockUseAuth.mockReturnValue({ user: null, loading: false, logout: jest.fn() });
|
|
28
|
+
render(<Profile />);
|
|
29
|
+
expect(screen.getByText("Not logged in.")).toBeTruthy();
|
|
30
|
+
expect(screen.getByText("Log in")).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("shows the signed-in user's details", () => {
|
|
34
|
+
mockUseAuth.mockReturnValue({
|
|
35
|
+
user: { id: 7, email: "me@x.com" },
|
|
36
|
+
loading: false,
|
|
37
|
+
logout: jest.fn(),
|
|
38
|
+
});
|
|
39
|
+
render(<Profile />);
|
|
40
|
+
expect(screen.getByText("Signed in ✓")).toBeTruthy();
|
|
41
|
+
expect(screen.getByText("Email: me@x.com")).toBeTruthy();
|
|
42
|
+
expect(screen.getByText("ID: 7")).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Link } from "expo-router";
|
|
2
|
+
import { Button, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { useAuth } from "@/auth/auth-context";
|
|
4
|
+
|
|
5
|
+
export default function Profile() {
|
|
6
|
+
const { user, loading, logout } = useAuth();
|
|
7
|
+
|
|
8
|
+
if (loading)
|
|
9
|
+
return (
|
|
10
|
+
<View style={styles.container}>
|
|
11
|
+
<Text>Loading…</Text>
|
|
12
|
+
</View>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (!user)
|
|
16
|
+
return (
|
|
17
|
+
<View style={styles.container}>
|
|
18
|
+
<Text>Not logged in.</Text>
|
|
19
|
+
<Link href="/login" style={styles.link}>
|
|
20
|
+
Log in
|
|
21
|
+
</Link>
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<View style={styles.container}>
|
|
27
|
+
<Text style={styles.title}>Signed in ✓</Text>
|
|
28
|
+
<Text>ID: {user.id}</Text>
|
|
29
|
+
<Text>Email: {user.email}</Text>
|
|
30
|
+
<Button title="Log out" onPress={() => logout()} />
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
container: { flex: 1, justifyContent: "center", padding: 24, gap: 8 },
|
|
37
|
+
title: { fontSize: 24, fontWeight: "bold" },
|
|
38
|
+
link: { color: "#2563eb" },
|
|
39
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "example",
|
|
3
|
+
"title": "Example screens",
|
|
4
|
+
"description": "A demo profile screen that reads the logged-in user from /api/auth/me.",
|
|
5
|
+
"kind": "feature",
|
|
6
|
+
"targets": ["web", "mobile"],
|
|
7
|
+
"requiresApps": ["server"],
|
|
8
|
+
"requires": ["auth"],
|
|
9
|
+
"default": true,
|
|
10
|
+
"wiring": {
|
|
11
|
+
"web": {
|
|
12
|
+
"routes": ["{ href: \"/profile\", label: \"My profile\" },"]
|
|
13
|
+
},
|
|
14
|
+
"mobile": {
|
|
15
|
+
"routes": ["{ href: \"/profile\", label: \"My profile\" },"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"notes": [
|
|
19
|
+
"Visit /profile (web) or the Profile screen (mobile) after logging in to see the wiring end-to-end."
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { render, screen } from "@testing-library/react";
|
|
5
|
+
|
|
6
|
+
const { useAuth } = vi.hoisted(() => ({ useAuth: vi.fn() }));
|
|
7
|
+
vi.mock("@/auth/auth-context", () => ({ useAuth }));
|
|
8
|
+
vi.mock("next/link", () => ({
|
|
9
|
+
default: ({ children, href }: { children: ReactNode; href: string }) => <a href={href}>{children}</a>,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import ProfilePage from "./page";
|
|
13
|
+
|
|
14
|
+
describe("example ProfilePage", () => {
|
|
15
|
+
beforeEach(() => useAuth.mockReset());
|
|
16
|
+
|
|
17
|
+
it("shows a loading state", () => {
|
|
18
|
+
useAuth.mockReturnValue({ user: null, loading: true, logout: vi.fn() });
|
|
19
|
+
render(<ProfilePage />);
|
|
20
|
+
expect(screen.getByText(/Loading/)).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("prompts to log in when there is no user", () => {
|
|
24
|
+
useAuth.mockReturnValue({ user: null, loading: false, logout: vi.fn() });
|
|
25
|
+
render(<ProfilePage />);
|
|
26
|
+
// the "Log in" link is a leaf element with the login href
|
|
27
|
+
expect(screen.getByText("Log in").getAttribute("href")).toBe("/login");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("shows the signed-in user's details", () => {
|
|
31
|
+
useAuth.mockReturnValue({
|
|
32
|
+
user: { id: 7, email: "me@x.com" },
|
|
33
|
+
loading: false,
|
|
34
|
+
logout: vi.fn(),
|
|
35
|
+
});
|
|
36
|
+
render(<ProfilePage />);
|
|
37
|
+
expect(screen.getByText("Signed in ✓")).toBeTruthy();
|
|
38
|
+
expect(screen.getByText(/me@x\.com/)).toBeTruthy();
|
|
39
|
+
expect(screen.getByText(/ID: 7/)).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useAuth } from "@/auth/auth-context";
|
|
5
|
+
|
|
6
|
+
export default function ProfilePage() {
|
|
7
|
+
const { user, loading, logout } = useAuth();
|
|
8
|
+
|
|
9
|
+
if (loading) return <main className="p-8">Loading…</main>;
|
|
10
|
+
|
|
11
|
+
if (!user)
|
|
12
|
+
return (
|
|
13
|
+
<main className="p-8">
|
|
14
|
+
Not logged in.{" "}
|
|
15
|
+
<Link href="/login" className="text-blue-600 underline">
|
|
16
|
+
Log in
|
|
17
|
+
</Link>
|
|
18
|
+
</main>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<main className="mx-auto max-w-xl p-8">
|
|
23
|
+
<h1 className="text-2xl font-bold">Signed in ✓</h1>
|
|
24
|
+
<p className="mt-2">ID: {user.id}</p>
|
|
25
|
+
<p>Email: {user.email}</p>
|
|
26
|
+
<button
|
|
27
|
+
className="mt-4 rounded bg-gray-800 px-3 py-1 text-white"
|
|
28
|
+
onClick={() => logout()}
|
|
29
|
+
>
|
|
30
|
+
Log out
|
|
31
|
+
</button>
|
|
32
|
+
</main>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "storage",
|
|
3
|
+
"title": "File storage (swappable)",
|
|
4
|
+
"description": "Abstract StorageProvider with Local and S3 backends; switch via STORAGE_BACKEND.",
|
|
5
|
+
"kind": "feature",
|
|
6
|
+
"targets": ["server"],
|
|
7
|
+
"requiresApps": ["server"],
|
|
8
|
+
"provides": "storage",
|
|
9
|
+
"default": false,
|
|
10
|
+
"env": {
|
|
11
|
+
"STORAGE_BACKEND": "local",
|
|
12
|
+
"AWS_STORAGE_BUCKET_NAME": "",
|
|
13
|
+
"AWS_REGION": "us-east-1",
|
|
14
|
+
"AWS_ACCESS_KEY_ID": "",
|
|
15
|
+
"AWS_SECRET_ACCESS_KEY": ""
|
|
16
|
+
},
|
|
17
|
+
"wiring": {
|
|
18
|
+
"server": {
|
|
19
|
+
"deps": ["boto3>=1.35"],
|
|
20
|
+
"settings": [
|
|
21
|
+
"STORAGE_BACKEND = env(\"STORAGE_BACKEND\", default=\"local\")",
|
|
22
|
+
"MEDIA_ROOT = BASE_DIR / \"media\"",
|
|
23
|
+
"MEDIA_URL = \"/media/\"",
|
|
24
|
+
"AWS_STORAGE_BUCKET_NAME = env(\"AWS_STORAGE_BUCKET_NAME\", default=\"\")",
|
|
25
|
+
"AWS_REGION = env(\"AWS_REGION\", default=\"us-east-1\")",
|
|
26
|
+
"AWS_ACCESS_KEY_ID = env(\"AWS_ACCESS_KEY_ID\", default=\"\")",
|
|
27
|
+
"AWS_SECRET_ACCESS_KEY = env(\"AWS_SECRET_ACCESS_KEY\", default=\"\")"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"notes": [
|
|
32
|
+
"Storage: switch backends with STORAGE_BACKEND=local|s3. Call get_storage() from storage.factory."
|
|
33
|
+
]
|
|
34
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import BinaryIO
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StorageProvider(ABC):
|
|
6
|
+
"""
|
|
7
|
+
Swappable file-storage backend. Concrete implementations (LocalStorage,
|
|
8
|
+
S3Storage, ...) are selected at runtime via settings.STORAGE_BACKEND.
|
|
9
|
+
|
|
10
|
+
This is the reference pattern for adding a new capability to the platform:
|
|
11
|
+
define an ABC here, ship one or more implementations, and select via config.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def save(self, key: str, content: BinaryIO) -> str:
|
|
16
|
+
"""Persist `content` under `key`; return a retrievable URL."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def url(self, key: str) -> str:
|
|
20
|
+
"""Return a URL for an already-stored object."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def delete(self, key: str) -> None:
|
|
24
|
+
"""Remove the object at `key` (no error if absent)."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.utils.module_loading import import_string
|
|
5
|
+
|
|
6
|
+
from .base import StorageProvider
|
|
7
|
+
|
|
8
|
+
# Short aliases; a fully-qualified dotted path is also accepted.
|
|
9
|
+
_BACKENDS = {
|
|
10
|
+
"local": "storage.local.LocalStorage",
|
|
11
|
+
"s3": "storage.s3.S3Storage",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache
|
|
16
|
+
def get_storage() -> StorageProvider:
|
|
17
|
+
"""Return the configured StorageProvider (see settings.STORAGE_BACKEND)."""
|
|
18
|
+
backend = getattr(settings, "STORAGE_BACKEND", "local")
|
|
19
|
+
dotted = _BACKENDS.get(backend, backend)
|
|
20
|
+
return import_string(dotted)()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import BinaryIO
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
|
|
6
|
+
from .base import StorageProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocalStorage(StorageProvider):
|
|
10
|
+
"""Stores files on the local filesystem under MEDIA_ROOT."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.root = Path(getattr(settings, "MEDIA_ROOT", settings.BASE_DIR / "media"))
|
|
14
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
self.base_url = getattr(settings, "MEDIA_URL", "/media/")
|
|
16
|
+
|
|
17
|
+
def save(self, key: str, content: BinaryIO) -> str:
|
|
18
|
+
dest = self.root / key
|
|
19
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
with open(dest, "wb") as fh:
|
|
21
|
+
fh.write(content.read())
|
|
22
|
+
return self.url(key)
|
|
23
|
+
|
|
24
|
+
def url(self, key: str) -> str:
|
|
25
|
+
return f"{self.base_url.rstrip('/')}/{key}"
|
|
26
|
+
|
|
27
|
+
def delete(self, key: str) -> None:
|
|
28
|
+
(self.root / key).unlink(missing_ok=True)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import BinaryIO
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
from .base import StorageProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class S3Storage(StorageProvider):
|
|
9
|
+
"""Stores files in an S3 bucket. Requires boto3 and AWS credentials."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
import boto3 # imported lazily so boto3 is only needed for the S3 backend
|
|
13
|
+
|
|
14
|
+
self.bucket = settings.AWS_STORAGE_BUCKET_NAME
|
|
15
|
+
self.client = boto3.client(
|
|
16
|
+
"s3",
|
|
17
|
+
region_name=getattr(settings, "AWS_REGION", None),
|
|
18
|
+
aws_access_key_id=getattr(settings, "AWS_ACCESS_KEY_ID", None) or None,
|
|
19
|
+
aws_secret_access_key=getattr(settings, "AWS_SECRET_ACCESS_KEY", None) or None,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def save(self, key: str, content: BinaryIO) -> str:
|
|
23
|
+
self.client.upload_fileobj(content, self.bucket, key)
|
|
24
|
+
return self.url(key)
|
|
25
|
+
|
|
26
|
+
def url(self, key: str) -> str:
|
|
27
|
+
return f"https://{self.bucket}.s3.amazonaws.com/{key}"
|
|
28
|
+
|
|
29
|
+
def delete(self, key: str) -> None:
|
|
30
|
+
self.client.delete_object(Bucket=self.bucket, Key=key)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_local_storage_roundtrip(tmp_path, settings):
|
|
5
|
+
settings.MEDIA_ROOT = tmp_path
|
|
6
|
+
settings.MEDIA_URL = "/media/"
|
|
7
|
+
settings.STORAGE_BACKEND = "local"
|
|
8
|
+
|
|
9
|
+
from storage.factory import get_storage
|
|
10
|
+
|
|
11
|
+
get_storage.cache_clear()
|
|
12
|
+
store = get_storage()
|
|
13
|
+
|
|
14
|
+
url = store.save("greeting.txt", io.BytesIO(b"hi"))
|
|
15
|
+
assert url.endswith("/greeting.txt")
|
|
16
|
+
assert (tmp_path / "greeting.txt").read_bytes() == b"hi"
|
|
17
|
+
|
|
18
|
+
store.delete("greeting.txt")
|
|
19
|
+
assert not (tmp_path / "greeting.txt").exists()
|
|
20
|
+
get_storage.cache_clear()
|