codz-cli 0.0.0-stage → 0.1.2
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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +525 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +638 -0
- package/dist/skills/createDemo.js +1026 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
// auth add: sign-in for an existing project, in the project's own style, as
|
|
2
|
+
// one previewed change.
|
|
3
|
+
//
|
|
4
|
+
// Next.js server actions for login and logout, the API token in an HttpOnly
|
|
5
|
+
// cookie, middleware that sends signed-out visitors to /login, a
|
|
6
|
+
// login page, and withAuth() to send the token from server code
|
|
7
|
+
// React Native the token in secure storage (expo-secure-store or react-native-keychain),
|
|
8
|
+
// sent by the fetch helper or an axios interceptor, sign out on 401,
|
|
9
|
+
// an AuthProvider with useAuth(), and a login screen
|
|
10
|
+
// Flutter the token in flutter_secure_storage, a Dio interceptor (token on every
|
|
11
|
+
// request, refresh or sign out on 401), an AuthService the router
|
|
12
|
+
// listens to (go_router redirect), and a login page
|
|
13
|
+
// Android the token in EncryptedSharedPreferences, an OkHttp interceptor,
|
|
14
|
+
// a Retrofit AuthApi, an AuthRepository and a login screen with a
|
|
15
|
+
// Hilt ViewModel (Hilt projects)
|
|
16
|
+
//
|
|
17
|
+
// The login screen comes from route add (so it is registered the project's
|
|
18
|
+
// way) and is then filled in. Everything runs in a private copy of the project
|
|
19
|
+
// and comes back as ONE change set.
|
|
20
|
+
import { posix } from 'node:path';
|
|
21
|
+
import { loadMap } from "../core/cache.js";
|
|
22
|
+
import { loadConfig } from "../core/config.js";
|
|
23
|
+
import { exists } from "../core/fsutil.js";
|
|
24
|
+
import { openWorkspace } from "../core/workspace.js";
|
|
25
|
+
import { ToolError } from "../core/types.js";
|
|
26
|
+
import { dq } from "../adapters/dartGen.js";
|
|
27
|
+
import { importPath } from "../adapters/nextjs.js";
|
|
28
|
+
import { addPubspecDeps } from "../adapters/flutterSetup.js";
|
|
29
|
+
import { gradleBlockAdd, tomlAdd } from "../adapters/androidSetup.js";
|
|
30
|
+
import { jsString, ktString } from "../core/safe.js";
|
|
31
|
+
import { planRouteAdd } from "./route.js";
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
const API_PATH = /^\/[A-Za-z0-9\-._~/]*$/;
|
|
34
|
+
const FIELD = /^[A-Za-z_][A-Za-z0-9_]{0,40}$/;
|
|
35
|
+
function resolve(o) {
|
|
36
|
+
const r = {
|
|
37
|
+
loginPath: o.loginPath ?? '/auth/login',
|
|
38
|
+
mePath: o.mePath ?? '/auth/me',
|
|
39
|
+
logoutPath: o.logoutPath ?? '/auth/logout',
|
|
40
|
+
refreshPath: o.refreshPath ?? '',
|
|
41
|
+
tokenField: o.tokenField ?? 'token',
|
|
42
|
+
refreshField: o.refreshField ?? 'refreshToken',
|
|
43
|
+
publicPaths: o.publicPaths ?? [],
|
|
44
|
+
};
|
|
45
|
+
for (const [k, v] of [['--login-path', r.loginPath], ['--me-path', r.mePath], ['--logout-path', r.logoutPath], ['--refresh-path', r.refreshPath]]) {
|
|
46
|
+
if (v && !API_PATH.test(v))
|
|
47
|
+
throw new ToolError('bad_args', `${k} "${v}" is not an API path.`, 'Use a path such as /auth/login (letters, digits, - _ . ~ and /).');
|
|
48
|
+
}
|
|
49
|
+
for (const [k, v] of [['--token-field', r.tokenField], ['--refresh-field', r.refreshField]]) {
|
|
50
|
+
if (!FIELD.test(v))
|
|
51
|
+
throw new ToolError('bad_args', `${k} "${v}" is not a field name.`, 'Use the JSON key, for example token or access_token.');
|
|
52
|
+
}
|
|
53
|
+
for (const p of r.publicPaths)
|
|
54
|
+
if (!/^\/[A-Za-z0-9\-._~/]*$/.test(p))
|
|
55
|
+
throw new ToolError('bad_args', `--public "${p}" is not a page path.`, 'Use a path such as /about.');
|
|
56
|
+
return r;
|
|
57
|
+
}
|
|
58
|
+
export async function planAuthAdd(root, adapter, opts) {
|
|
59
|
+
const o = resolve(opts);
|
|
60
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|
|
61
|
+
if (!['nextjs', 'react-native', 'flutter', 'android'].includes(adapter.id))
|
|
62
|
+
throw new ToolError('not_supported', `auth add supports Next.js, React Native, Flutter and Android; this project uses the ${adapter.id} adapter.`, adapter.id === 'react' || adapter.id === 'ios' ? `Sign-in for ${adapter.id === 'react' ? 'React web apps' : 'iOS'} is not written yet: add your login call with codz api add, then a form with codz screen add login --api <endpoint> --form.` : 'Run codz init to check which framework codz detects here.');
|
|
63
|
+
const ws = openWorkspace(root);
|
|
64
|
+
try {
|
|
65
|
+
const summary = [];
|
|
66
|
+
const warnings = [];
|
|
67
|
+
let login;
|
|
68
|
+
try {
|
|
69
|
+
login = await planRouteAdd(ws.root, adapter, { name: 'login', path: '/login' });
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e instanceof ToolError && /already exists|already has/.test(e.message))
|
|
73
|
+
throw new ToolError('auth_exists', `This project already has a login screen (${e.message})`, 'auth add creates its own; remove or rename the existing one first, or wire it to your sign-in code by hand.');
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
if (!login.changes.length)
|
|
77
|
+
throw new ToolError('auth_exists', 'This project already has a login route.', 'Remove it first, or wire your own screen to the generated auth code by hand.');
|
|
78
|
+
ws.apply(login);
|
|
79
|
+
// The page itself: a GetX module also creates a binding and a controller, which must stay as they are.
|
|
80
|
+
const created = login.changes.filter((ch) => ch.before === null && ch.after !== null).map((ch) => ch.path);
|
|
81
|
+
const page = created.find((p) => /\/views\/\w+_view\.dart$/.test(p)) ?? created.find((p) => /(page|screen|login)\.(dart|tsx|jsx|kt)$|\/page\.(tsx|jsx)$/i.test(p)) ?? created[0];
|
|
82
|
+
if (!page)
|
|
83
|
+
throw new ToolError('internal', 'route add did not create a login page.');
|
|
84
|
+
if (adapter.id === 'nextjs')
|
|
85
|
+
nextAuth(ws, map, page, o, summary, warnings);
|
|
86
|
+
else if (adapter.id === 'react-native')
|
|
87
|
+
rnAuth(ws, map, page, o, summary, warnings);
|
|
88
|
+
else if (adapter.id === 'flutter')
|
|
89
|
+
flutterAuth(ws, map, page, o, summary, warnings);
|
|
90
|
+
else
|
|
91
|
+
androidAuth(ws, map, page, o, summary, warnings);
|
|
92
|
+
summary.unshift(`Sign-in for ${adapter.id === 'nextjs' ? 'Next.js' : adapter.id === 'react-native' ? 'React Native' : adapter.id === 'flutter' ? 'Flutter' : 'Android'}: POST ${o.loginPath} with { email, password } → { ${o.tokenField} }, GET ${o.mePath} for the user`);
|
|
93
|
+
const changeSet = ws.finish('auth add', summary, warnings);
|
|
94
|
+
return { changeSet, framework: adapter.id, files: changeSet.changes.map((c) => c.path) };
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
ws.dispose();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Next.js
|
|
102
|
+
function nextAuth(ws, map, page, o, summary, warnings) {
|
|
103
|
+
const c = map.conventions;
|
|
104
|
+
const q = c.quote === 'double' ? '"' : "'";
|
|
105
|
+
const s = (v) => jsString(v, q);
|
|
106
|
+
const appDir = c.appDir || 'app';
|
|
107
|
+
const srcRoot = appDir.startsWith('src/') ? 'src' : '';
|
|
108
|
+
const helperFile = c.httpHelperFile || '';
|
|
109
|
+
const libDir = helperFile ? posix.dirname(posix.dirname(helperFile)) : posix.join(srcRoot, 'lib');
|
|
110
|
+
const authDir = posix.join(libDir === '.' ? '' : libDir, 'auth');
|
|
111
|
+
const env = c.baseUrlEnv && /^\w+$/.test(c.baseUrlEnv) ? c.baseUrlEnv : 'API_URL';
|
|
112
|
+
const f = (name) => posix.join(authDir, name);
|
|
113
|
+
const imp = (from, to) => importPath(from, to, c);
|
|
114
|
+
ws.write(f('cookie.ts'), `/** The cookie that holds the API token. HttpOnly, so browser scripts cannot read it. */\nexport const SESSION_COOKIE = ${s('session')};\n`);
|
|
115
|
+
ws.write(f('api.ts'), `// Calls to the sign-in endpoints. Change the paths here if your API uses others.
|
|
116
|
+
const BASE_URL = process.env.${env} ?? '';
|
|
117
|
+
|
|
118
|
+
export interface AuthUser {
|
|
119
|
+
id?: string | number;
|
|
120
|
+
name?: string;
|
|
121
|
+
email?: string;
|
|
122
|
+
[key: string]: unknown;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function call<T>(path: string, init: RequestInit, token?: string): Promise<T> {
|
|
126
|
+
const headers = new Headers(init.headers);
|
|
127
|
+
headers.set('Accept', 'application/json');
|
|
128
|
+
if (init.body) headers.set('Content-Type', 'application/json');
|
|
129
|
+
if (token) headers.set('Authorization', \`Bearer \${token}\`);
|
|
130
|
+
const res = await fetch(\`\${BASE_URL}\${path}\`, { cache: 'no-store', ...init, headers });
|
|
131
|
+
if (!res.ok) throw Object.assign(new Error(\`\${init.method ?? 'GET'} \${path} failed (\${res.status})\`), { status: res.status });
|
|
132
|
+
if (res.status === 204) return undefined as T;
|
|
133
|
+
return (await res.json()) as T;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** POST ${o.loginPath} → the token from "${o.tokenField}". */
|
|
137
|
+
export async function requestLogin(email: string, password: string): Promise<string> {
|
|
138
|
+
const data = await call<Record<string, unknown>>(${s(o.loginPath)}, { method: 'POST', body: JSON.stringify({ email, password }) });
|
|
139
|
+
const token = data[${s(o.tokenField)}];
|
|
140
|
+
if (typeof token !== 'string' || !token) throw new Error('The login answer has no ${o.tokenField} field.');
|
|
141
|
+
return token;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** GET ${o.mePath} with the token. */
|
|
145
|
+
export function requestMe(token: string): Promise<AuthUser> {
|
|
146
|
+
return call<AuthUser>(${s(o.mePath)}, { method: 'GET' }, token);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** POST ${o.logoutPath} with the token (errors are ignored: the cookie is removed either way). */
|
|
150
|
+
export async function requestLogout(token: string): Promise<void> {
|
|
151
|
+
await call<void>(${s(o.logoutPath)}, { method: 'POST' }, token).catch(() => undefined);
|
|
152
|
+
}
|
|
153
|
+
`);
|
|
154
|
+
ws.write(f('session.ts'), `import { cookies } from 'next/headers';
|
|
155
|
+
import { redirect } from 'next/navigation';
|
|
156
|
+
|
|
157
|
+
import { requestMe, type AuthUser } from ${s(imp(f('session.ts'), f('api.ts')))};
|
|
158
|
+
import { SESSION_COOKIE } from ${s(imp(f('session.ts'), f('cookie.ts')))};
|
|
159
|
+
|
|
160
|
+
const MAX_AGE = 60 * 60 * 24 * 30;
|
|
161
|
+
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
|
162
|
+
|
|
163
|
+
export async function getToken(): Promise<string | undefined> {
|
|
164
|
+
return (await cookies()).get(SESSION_COOKIE)?.value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function setSession(token: string): Promise<void> {
|
|
168
|
+
(await cookies()).set(SESSION_COOKIE, token, { httpOnly: true, secure: IS_PRODUCTION, sameSite: 'lax', path: '/', maxAge: MAX_AGE });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function clearSession(): Promise<void> {
|
|
172
|
+
(await cookies()).delete(SESSION_COOKIE);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The signed-in user, or null (also when the token has expired). */
|
|
176
|
+
export async function getCurrentUser(): Promise<AuthUser | null> {
|
|
177
|
+
const token = await getToken();
|
|
178
|
+
if (!token) return null;
|
|
179
|
+
try {
|
|
180
|
+
return await requestMe(token);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if ((e as { status?: number }).status === 401) return null;
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** For server pages behind sign-in: the user, or a redirect to /login. */
|
|
188
|
+
export async function requireUser(): Promise<AuthUser> {
|
|
189
|
+
const user = await getCurrentUser();
|
|
190
|
+
if (!user) redirect('/login');
|
|
191
|
+
return user;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Adds the signed-in user's token to a request made from server code, for the API functions codz writes:
|
|
196
|
+
* const orders = await getOrders(await withAuth()); // when the function takes RequestInit
|
|
197
|
+
* await apiFetch('/orders', await withAuth({ method: 'POST', body }));
|
|
198
|
+
*/
|
|
199
|
+
export async function withAuth(init: RequestInit = {}): Promise<RequestInit> {
|
|
200
|
+
const token = await getToken();
|
|
201
|
+
if (!token) return init;
|
|
202
|
+
const headers = new Headers(init.headers);
|
|
203
|
+
headers.set('Authorization', \`Bearer \${token}\`);
|
|
204
|
+
return { ...init, headers };
|
|
205
|
+
}
|
|
206
|
+
`);
|
|
207
|
+
ws.write(f('actions.ts'), `'use server';
|
|
208
|
+
|
|
209
|
+
import { redirect } from 'next/navigation';
|
|
210
|
+
|
|
211
|
+
import { requestLogin, requestLogout } from ${s(imp(f('actions.ts'), f('api.ts')))};
|
|
212
|
+
import { clearSession, getToken, setSession } from ${s(imp(f('actions.ts'), f('session.ts')))};
|
|
213
|
+
|
|
214
|
+
export interface LoginState {
|
|
215
|
+
error?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Signs in with the API and keeps the token in an HttpOnly cookie. */
|
|
219
|
+
export async function login(_prev: LoginState, form: FormData): Promise<LoginState> {
|
|
220
|
+
const email = String(form.get('email') ?? '').trim();
|
|
221
|
+
const password = String(form.get('password') ?? '');
|
|
222
|
+
if (!email || !password) return { error: 'Enter your email and password.' };
|
|
223
|
+
try {
|
|
224
|
+
await setSession(await requestLogin(email, password));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
const status = (e as { status?: number }).status;
|
|
227
|
+
return { error: status === 401 || status === 400 ? 'That email and password do not match.' : 'Sign-in failed. Try again.' };
|
|
228
|
+
}
|
|
229
|
+
const next = String(form.get('next') ?? '');
|
|
230
|
+
redirect(next.startsWith('/') && !next.startsWith('//') ? next : '/');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function logout(): Promise<void> {
|
|
234
|
+
const token = await getToken();
|
|
235
|
+
if (token) await requestLogout(token);
|
|
236
|
+
await clearSession();
|
|
237
|
+
redirect('/login');
|
|
238
|
+
}
|
|
239
|
+
`);
|
|
240
|
+
const ext = page.endsWith('.jsx') ? 'jsx' : 'tsx';
|
|
241
|
+
const formFile = posix.join(posix.dirname(page), `LoginForm.${ext}`);
|
|
242
|
+
ws.write(formFile, `'use client';
|
|
243
|
+
|
|
244
|
+
import { useActionState } from 'react';
|
|
245
|
+
|
|
246
|
+
import { login, type LoginState } from ${s(imp(formFile, f('actions.ts')))};
|
|
247
|
+
|
|
248
|
+
export default function LoginForm({ next }: { next: string }) {
|
|
249
|
+
const [state, action, pending] = useActionState<LoginState, FormData>(login, {});
|
|
250
|
+
return (
|
|
251
|
+
<form action={action} style={{ display: 'grid', gap: 12, maxWidth: 360 }}>
|
|
252
|
+
<input type="hidden" name="next" value={next} />
|
|
253
|
+
<label>
|
|
254
|
+
Email
|
|
255
|
+
<input name="email" type="email" autoComplete="email" required />
|
|
256
|
+
</label>
|
|
257
|
+
<label>
|
|
258
|
+
Password
|
|
259
|
+
<input name="password" type="password" autoComplete="current-password" required />
|
|
260
|
+
</label>
|
|
261
|
+
{state.error ? <p role="alert">{state.error}</p> : null}
|
|
262
|
+
<button disabled={pending}>{pending ? 'Signing in…' : 'Sign in'}</button>
|
|
263
|
+
</form>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
`);
|
|
267
|
+
ws.write(page, `import LoginForm from './LoginForm';
|
|
268
|
+
|
|
269
|
+
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
|
|
270
|
+
const { next } = await searchParams;
|
|
271
|
+
return (
|
|
272
|
+
<main>
|
|
273
|
+
<h1>Sign in</h1>
|
|
274
|
+
<LoginForm next={next ?? '/'} />
|
|
275
|
+
</main>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
`);
|
|
279
|
+
const middleware = posix.join(srcRoot, 'middleware.ts');
|
|
280
|
+
const publicList = ['/login', ...o.publicPaths];
|
|
281
|
+
if (exists(join(ws.root, middleware)) || exists(join(ws.root, posix.join(srcRoot, 'middleware.js')))) {
|
|
282
|
+
warnings.push(`${middleware} already exists, so it was left alone. To protect pages, redirect to /login there when the "session" cookie is missing.`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
ws.write(middleware, `import { NextResponse, type NextRequest } from 'next/server';
|
|
286
|
+
|
|
287
|
+
import { SESSION_COOKIE } from ${s(imp(middleware, f('cookie.ts')))};
|
|
288
|
+
|
|
289
|
+
/** Pages anyone may open. Every other page needs a session cookie (its token is checked by the API on each call). */
|
|
290
|
+
const PUBLIC = [${publicList.map((p) => s(p)).join(', ')}];
|
|
291
|
+
|
|
292
|
+
export function middleware(req: NextRequest) {
|
|
293
|
+
const path = req.nextUrl.pathname;
|
|
294
|
+
if (PUBLIC.some((p) => path === p || path.startsWith(\`\${p}/\`)) || req.cookies.has(SESSION_COOKIE)) return NextResponse.next();
|
|
295
|
+
const url = new URL('/login', req.url);
|
|
296
|
+
url.searchParams.set('next', path);
|
|
297
|
+
return NextResponse.redirect(url);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const config = {
|
|
301
|
+
// Not for static files, images or API routes.
|
|
302
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/|.*\\\\..*).*)'],
|
|
303
|
+
};
|
|
304
|
+
`);
|
|
305
|
+
summary.push(`${middleware}: every page except ${publicList.join(', ')} needs a session (add public pages with --public)`);
|
|
306
|
+
}
|
|
307
|
+
if (!c.baseUrlEnv)
|
|
308
|
+
warnings.push(`No base URL setting was found; ${f('api.ts')} reads process.env.API_URL. Set it in .env.local.`);
|
|
309
|
+
summary.push(`${f('api.ts')}, ${f('session.ts')}, ${f('actions.ts')}: sign in, sign out, getCurrentUser(), requireUser(), withAuth()`, `${page} and ${formFile}: the sign-in page`, 'From server code, send the token with withAuth(): apiFetch(path, await withAuth(init)). Sign out with the logout server action.');
|
|
310
|
+
}
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// React Native
|
|
313
|
+
function rnAuth(ws, map, page, o, summary, warnings) {
|
|
314
|
+
const c = map.conventions;
|
|
315
|
+
const q = c.quote === 'double' ? '"' : "'";
|
|
316
|
+
const s = (v) => jsString(v, q);
|
|
317
|
+
const srcRoot = c.aliasBase && c.aliasBase !== '.' ? c.aliasBase : posix.dirname(posix.dirname(page));
|
|
318
|
+
const authDir = posix.join(srcRoot, 'auth');
|
|
319
|
+
const f = (name) => posix.join(authDir, name);
|
|
320
|
+
const rel = (from, to) => {
|
|
321
|
+
const r = posix.relative(posix.dirname(from), to.replace(/\.(tsx?|jsx?)$/, ''));
|
|
322
|
+
return r.startsWith('.') ? r : `./${r}`;
|
|
323
|
+
};
|
|
324
|
+
const expo = c.flavor !== 'bare';
|
|
325
|
+
const axios = c.http === 'axios';
|
|
326
|
+
// Secure storage.
|
|
327
|
+
ws.write(f('tokenStore.ts'), expo
|
|
328
|
+
? `import * as SecureStore from 'expo-secure-store';
|
|
329
|
+
|
|
330
|
+
const ACCESS = 'auth_token';
|
|
331
|
+
const REFRESH = 'auth_refresh';
|
|
332
|
+
let cached: string | null | undefined;
|
|
333
|
+
|
|
334
|
+
/** The API tokens, in the Keychain (iOS) or the Keystore (Android), with an in-memory copy. */
|
|
335
|
+
export const tokenStore = {
|
|
336
|
+
async get(): Promise<string | null> {
|
|
337
|
+
if (cached === undefined) cached = await SecureStore.getItemAsync(ACCESS);
|
|
338
|
+
return cached ?? null;
|
|
339
|
+
},
|
|
340
|
+
getRefresh: () => SecureStore.getItemAsync(REFRESH),
|
|
341
|
+
async set(token: string, refresh?: string | null): Promise<void> {
|
|
342
|
+
cached = token;
|
|
343
|
+
await SecureStore.setItemAsync(ACCESS, token);
|
|
344
|
+
if (refresh) await SecureStore.setItemAsync(REFRESH, refresh);
|
|
345
|
+
},
|
|
346
|
+
async clear(): Promise<void> {
|
|
347
|
+
cached = null;
|
|
348
|
+
await SecureStore.deleteItemAsync(ACCESS);
|
|
349
|
+
await SecureStore.deleteItemAsync(REFRESH);
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
`
|
|
353
|
+
: `import * as Keychain from 'react-native-keychain';
|
|
354
|
+
|
|
355
|
+
let cached: { token: string | null; refresh: string | null } | undefined;
|
|
356
|
+
|
|
357
|
+
async function load() {
|
|
358
|
+
if (cached === undefined) {
|
|
359
|
+
const c = await Keychain.getGenericPassword({ service: 'auth' });
|
|
360
|
+
cached = c ? (JSON.parse(c.password) as { token: string | null; refresh: string | null }) : { token: null, refresh: null };
|
|
361
|
+
}
|
|
362
|
+
return cached;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The API tokens, in the Keychain (iOS) or the Keystore (Android), with an in-memory copy. */
|
|
366
|
+
export const tokenStore = {
|
|
367
|
+
get: async () => (await load()).token,
|
|
368
|
+
getRefresh: async () => (await load()).refresh,
|
|
369
|
+
async set(token: string, refresh?: string | null): Promise<void> {
|
|
370
|
+
cached = { token, refresh: refresh ?? (await load()).refresh };
|
|
371
|
+
await Keychain.setGenericPassword('auth', JSON.stringify(cached), { service: 'auth' });
|
|
372
|
+
},
|
|
373
|
+
async clear(): Promise<void> {
|
|
374
|
+
cached = { token: null, refresh: null };
|
|
375
|
+
await Keychain.resetGenericPassword({ service: 'auth' });
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
`);
|
|
379
|
+
// Base URL: the config file's API_URL when it has one.
|
|
380
|
+
const configText = c.configFile ? ws.read(c.configFile) : null;
|
|
381
|
+
const hasApiUrl = !!configText && /export const API_URL\b/.test(configText);
|
|
382
|
+
const baseImport = hasApiUrl ? `import { API_URL } from ${s(rel(f('session.ts'), c.configFile))};\n` : '';
|
|
383
|
+
const baseExpr = hasApiUrl ? 'API_URL' : "process.env.EXPO_PUBLIC_API_URL ?? ''";
|
|
384
|
+
if (!hasApiUrl)
|
|
385
|
+
warnings.push(`No API_URL export was found in the config file; ${f('session.ts')} uses EXPO_PUBLIC_API_URL.`);
|
|
386
|
+
ws.write(f('session.ts'), `${baseImport}import { tokenStore } from './tokenStore';
|
|
387
|
+
|
|
388
|
+
// Calls to the sign-in endpoints. Change the paths here if your API uses others.
|
|
389
|
+
export interface AuthUser {
|
|
390
|
+
id?: string | number;
|
|
391
|
+
name?: string;
|
|
392
|
+
email?: string;
|
|
393
|
+
[key: string]: unknown;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function call<T>(path: string, init: RequestInit, token?: string | null): Promise<T> {
|
|
397
|
+
const headers: Record<string, string> = { Accept: 'application/json', ...(init.body ? { 'Content-Type': 'application/json' } : {}) };
|
|
398
|
+
if (token) headers.Authorization = \`Bearer \${token}\`;
|
|
399
|
+
const res = await fetch(\`\${${baseExpr}}\${path}\`, { ...init, headers: { ...headers, ...(init.headers as Record<string, string> | undefined) } });
|
|
400
|
+
if (!res.ok) throw Object.assign(new Error(\`\${init.method ?? 'GET'} \${path} failed (\${res.status})\`), { status: res.status });
|
|
401
|
+
if (res.status === 204) return undefined as T;
|
|
402
|
+
return (await res.json()) as T;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function requestLogin(email: string, password: string): Promise<void> {
|
|
406
|
+
const data = await call<Record<string, unknown>>(${s(o.loginPath)}, { method: 'POST', body: JSON.stringify({ email, password }) });
|
|
407
|
+
const token = data[${s(o.tokenField)}];
|
|
408
|
+
if (typeof token !== 'string' || !token) throw new Error('The login answer has no ${o.tokenField} field.');
|
|
409
|
+
const refresh = data[${s(o.refreshField)}];
|
|
410
|
+
await tokenStore.set(token, typeof refresh === 'string' ? refresh : null);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export const requestMe = async () => call<AuthUser>(${s(o.mePath)}, { method: 'GET' }, await tokenStore.get());
|
|
414
|
+
|
|
415
|
+
export async function requestLogout(): Promise<void> {
|
|
416
|
+
await call<void>(${s(o.logoutPath)}, { method: 'POST' }, await tokenStore.get()).catch(() => undefined);
|
|
417
|
+
await tokenStore.clear();
|
|
418
|
+
}
|
|
419
|
+
${o.refreshPath
|
|
420
|
+
? `
|
|
421
|
+
/** Exchanges the refresh token for a new access token. Returns false when that is not possible. */
|
|
422
|
+
export async function refreshSession(): Promise<boolean> {
|
|
423
|
+
const refresh = await tokenStore.getRefresh();
|
|
424
|
+
if (!refresh) return false;
|
|
425
|
+
try {
|
|
426
|
+
const data = await call<Record<string, unknown>>(${s(o.refreshPath)}, { method: 'POST', body: JSON.stringify({ refreshToken: refresh }) });
|
|
427
|
+
const token = data[${s(o.tokenField)}];
|
|
428
|
+
if (typeof token !== 'string') return false;
|
|
429
|
+
const next = data[${s(o.refreshField)}];
|
|
430
|
+
await tokenStore.set(token, typeof next === 'string' ? next : refresh);
|
|
431
|
+
return true;
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
`
|
|
437
|
+
: ''}
|
|
438
|
+
let onUnauthorized: (() => void) | null = null;
|
|
439
|
+
|
|
440
|
+
/** AuthProvider registers this; a 401 from any API call signs the user out. */
|
|
441
|
+
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
|
442
|
+
onUnauthorized = handler;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function notifyUnauthorized(): void {
|
|
446
|
+
onUnauthorized?.();
|
|
447
|
+
}
|
|
448
|
+
`);
|
|
449
|
+
ws.write(f('AuthContext.tsx'), `import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
450
|
+
|
|
451
|
+
import { requestLogin, requestLogout, requestMe, setUnauthorizedHandler, type AuthUser } from './session';
|
|
452
|
+
import { tokenStore } from './tokenStore';
|
|
453
|
+
|
|
454
|
+
type Status = 'loading' | 'signedOut' | 'signedIn';
|
|
455
|
+
|
|
456
|
+
interface AuthValue {
|
|
457
|
+
status: Status;
|
|
458
|
+
user: AuthUser | null;
|
|
459
|
+
signIn: (email: string, password: string) => Promise<void>;
|
|
460
|
+
signOut: () => Promise<void>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const AuthContext = createContext<AuthValue | null>(null);
|
|
464
|
+
|
|
465
|
+
/** Restores the session at launch; signIn and signOut for the screens. */
|
|
466
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
467
|
+
const [status, setStatus] = useState<Status>('loading');
|
|
468
|
+
const [user, setUser] = useState<AuthUser | null>(null);
|
|
469
|
+
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
setUnauthorizedHandler(() => {
|
|
472
|
+
void tokenStore.clear();
|
|
473
|
+
setUser(null);
|
|
474
|
+
setStatus('signedOut');
|
|
475
|
+
});
|
|
476
|
+
(async () => {
|
|
477
|
+
if (!(await tokenStore.get())) return setStatus('signedOut');
|
|
478
|
+
try {
|
|
479
|
+
setUser(await requestMe());
|
|
480
|
+
setStatus('signedIn');
|
|
481
|
+
} catch {
|
|
482
|
+
setStatus('signedOut');
|
|
483
|
+
}
|
|
484
|
+
})();
|
|
485
|
+
return () => setUnauthorizedHandler(null);
|
|
486
|
+
}, []);
|
|
487
|
+
|
|
488
|
+
const signIn = useCallback(async (email: string, password: string) => {
|
|
489
|
+
await requestLogin(email, password);
|
|
490
|
+
setUser(await requestMe().catch(() => null));
|
|
491
|
+
setStatus('signedIn');
|
|
492
|
+
}, []);
|
|
493
|
+
|
|
494
|
+
const signOut = useCallback(async () => {
|
|
495
|
+
await requestLogout();
|
|
496
|
+
setUser(null);
|
|
497
|
+
setStatus('signedOut');
|
|
498
|
+
}, []);
|
|
499
|
+
|
|
500
|
+
const value = useMemo(() => ({ status, user, signIn, signOut }), [status, user, signIn, signOut]);
|
|
501
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function useAuth(): AuthValue {
|
|
505
|
+
const ctx = useContext(AuthContext);
|
|
506
|
+
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>.');
|
|
507
|
+
return ctx;
|
|
508
|
+
}
|
|
509
|
+
`);
|
|
510
|
+
// Token on every request.
|
|
511
|
+
let wired = false;
|
|
512
|
+
if (axios && c.axiosInstanceFile) {
|
|
513
|
+
const t = ws.read(c.axiosInstanceFile);
|
|
514
|
+
const inst = (c.axiosInstance || 'api').replace(/^default:/, '');
|
|
515
|
+
if (t && /^\w+$/.test(inst) && new RegExp(`\\b${inst}\\b`).test(t) && !/tokenStore/.test(t)) {
|
|
516
|
+
const refresh = o.refreshPath
|
|
517
|
+
? `\n const original = error.config as (typeof error.config & { _retried?: boolean }) | undefined;\n if (original && !original._retried && (await refreshSession())) {\n original._retried = true;\n return ${inst}(original);\n }`
|
|
518
|
+
: '';
|
|
519
|
+
ws.write(c.axiosInstanceFile, `${t.replace(/\s*$/, '\n')}
|
|
520
|
+
// Sign-in (codz auth add): the token on every request; a 401 ${o.refreshPath ? 'tries one refresh, then ' : ''}signs out.
|
|
521
|
+
${inst}.interceptors.request.use(async (config) => {
|
|
522
|
+
const token = await tokenStore.get();
|
|
523
|
+
if (token) config.headers.Authorization = \`Bearer \${token}\`;
|
|
524
|
+
return config;
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
${inst}.interceptors.response.use(undefined, async (error: AxiosError) => {
|
|
528
|
+
if (error.response?.status === 401) {${refresh}
|
|
529
|
+
notifyUnauthorized();
|
|
530
|
+
}
|
|
531
|
+
return Promise.reject(error);
|
|
532
|
+
});
|
|
533
|
+
`.replace(/^/, ''));
|
|
534
|
+
// Imports at the top.
|
|
535
|
+
let t2 = ws.read(c.axiosInstanceFile);
|
|
536
|
+
t2 = t2.replace(/import axios(,? ?\{?[^}\n]*\}?)? from ['"]axios['"];?/, (m) => (/AxiosError/.test(m) ? m : m.includes('{') ? m.replace('{', '{ AxiosError, ').replace(/,\s*,/, ',') : m.replace(/import axios/, 'import axios, { AxiosError }')));
|
|
537
|
+
const authImports = `import { ${['notifyUnauthorized', ...(o.refreshPath ? ['refreshSession'] : [])].join(', ')} } from ${s(rel(c.axiosInstanceFile, f('session.ts')))};\nimport { tokenStore } from ${s(rel(c.axiosInstanceFile, f('tokenStore.ts')))};\n`;
|
|
538
|
+
t2 = t2.replace(/(import [^\n]+\n)(?![\s\S]*\nimport )/, `$1${authImports}`);
|
|
539
|
+
ws.write(c.axiosInstanceFile, t2);
|
|
540
|
+
wired = true;
|
|
541
|
+
summary.push(`${c.axiosInstanceFile}: interceptors send the token and sign out on 401`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
else if (c.httpHelperFile) {
|
|
545
|
+
const t = ws.read(c.httpHelperFile);
|
|
546
|
+
const helper = c.httpHelper || 'apiFetch';
|
|
547
|
+
if (t && !/tokenStore/.test(t)) {
|
|
548
|
+
// The helper codz writes builds headers in one object literal; the token goes into it.
|
|
549
|
+
const re = /headers: \{ ('Content-Type': 'application\/json',[^}]*?)\.\.\.init\.headers \}/;
|
|
550
|
+
if (re.test(t) && new RegExp(`export async function ${helper}`).test(t)) {
|
|
551
|
+
let t2 = t.replace(re, (_m, before) => `headers: { ${before}...(token ? { Authorization: \`Bearer \${token}\` } : {}), ...init.headers }`);
|
|
552
|
+
t2 = t2.replace(new RegExp(`(export async function ${helper}<T>\\([^)]*\\)[^{]*\\{\\n)`), `$1 const token = await tokenStore.get();\n`);
|
|
553
|
+
// A 401 signs out: right before the error is thrown.
|
|
554
|
+
t2 = t2.replace(/(\n(\s*)if \(!res\.ok\) throw new ApiError\(res\.status)/, `\n$2if (res.status === 401) notifyUnauthorized();$1`);
|
|
555
|
+
t2 = t2.replace(/(import [^\n]+\n)(?![\s\S]*\nimport )/, `$1import { notifyUnauthorized } from ${s(rel(c.httpHelperFile, f('session.ts')))};\nimport { tokenStore } from ${s(rel(c.httpHelperFile, f('tokenStore.ts')))};\n`);
|
|
556
|
+
if (!/^import /m.test(t))
|
|
557
|
+
t2 = `import { notifyUnauthorized } from ${s(rel(c.httpHelperFile, f('session.ts')))};\nimport { tokenStore } from ${s(rel(c.httpHelperFile, f('tokenStore.ts')))};\n\n${t2}`;
|
|
558
|
+
ws.write(c.httpHelperFile, t2);
|
|
559
|
+
wired = true;
|
|
560
|
+
summary.push(`${c.httpHelperFile}: ${helper}() sends the token and signs out on 401`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (wired && o.refreshPath && !axios)
|
|
565
|
+
warnings.push('--refresh-path is used by the axios interceptor; with the fetch helper a 401 signs out (call refreshSession() from session.ts yourself to retry).');
|
|
566
|
+
if (!wired)
|
|
567
|
+
warnings.push(`The HTTP client was not changed (its shape is not the one codz writes). Send "Authorization: Bearer <await tokenStore.get()>" on each request, and call notifyUnauthorized() on a 401.`);
|
|
568
|
+
// The provider around the app.
|
|
569
|
+
const appFile = ['App.tsx', 'App.jsx', 'App.js'].find((p) => ws.read(p) !== null);
|
|
570
|
+
const appText = appFile ? ws.read(appFile) : null;
|
|
571
|
+
if (appFile && appText && /<NavigationContainer[\s>]/.test(appText) && !/AuthProvider/.test(appText)) {
|
|
572
|
+
let t = appText.replace(/(\n(\s*))<NavigationContainer([\s\S]*?)<\/NavigationContainer>/, (_m, lead, indent, inner) => `${lead}<AuthProvider>${lead} <NavigationContainer${inner.replace(/\n/g, '\n ')}</NavigationContainer>${lead}</AuthProvider>`);
|
|
573
|
+
t = t.replace(/(import [^\n]+\n)(?![\s\S]*\nimport )/, `$1import { AuthProvider } from ${s(rel(appFile, f('AuthContext.tsx')))};\n`);
|
|
574
|
+
ws.write(appFile, t);
|
|
575
|
+
summary.push(`${appFile}: the app is wrapped in <AuthProvider>`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
warnings.push(`Wrap your app's root in <AuthProvider> (from ${f('AuthContext.tsx')}); it was not found in App.tsx.`);
|
|
579
|
+
}
|
|
580
|
+
// The login screen.
|
|
581
|
+
const nav = c.navigatorFile ? ws.read(c.navigatorFile) : null;
|
|
582
|
+
const first = nav ? /<Stack\.Screen name="(\w+)"/.exec(nav)?.[1] : undefined;
|
|
583
|
+
const expoRouter = c.router === 'expo-router';
|
|
584
|
+
const after = expoRouter ? "router.replace('/')" : first && first !== 'Login' ? `navigation.reset({ index: 0, routes: [{ name: '${first}' }] })` : 'navigation.goBack()';
|
|
585
|
+
const navImports = expoRouter
|
|
586
|
+
? "import { router } from 'expo-router';\n"
|
|
587
|
+
: `import { useNavigation } from '@react-navigation/native';\nimport type { NativeStackNavigationProp } from '@react-navigation/native-stack';\n${c.navigatorFile ? `import type { RootStackParamList } from ${s(rel(page, c.navigatorFile))};\n` : ''}`;
|
|
588
|
+
ws.write(page, `import { useState } from 'react';
|
|
589
|
+
import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
590
|
+
${navImports}
|
|
591
|
+
import { useAuth } from ${s(rel(page, f('AuthContext.tsx')))};
|
|
592
|
+
|
|
593
|
+
export default function LoginScreen() {
|
|
594
|
+
const { signIn } = useAuth();
|
|
595
|
+
${expoRouter ? '' : ` const navigation = useNavigation<NativeStackNavigationProp<${c.navigatorFile ? 'RootStackParamList' : 'Record<string, undefined>'}>>();\n`} const [email, setEmail] = useState('');
|
|
596
|
+
const [password, setPassword] = useState('');
|
|
597
|
+
const [error, setError] = useState<string | null>(null);
|
|
598
|
+
const [busy, setBusy] = useState(false);
|
|
599
|
+
|
|
600
|
+
const submit = async () => {
|
|
601
|
+
if (!email.trim() || !password) return setError('Enter your email and password.');
|
|
602
|
+
setBusy(true);
|
|
603
|
+
setError(null);
|
|
604
|
+
try {
|
|
605
|
+
await signIn(email.trim(), password);
|
|
606
|
+
${after};
|
|
607
|
+
} catch (e) {
|
|
608
|
+
const status = (e as { status?: number; response?: { status?: number } }).status ?? (e as { response?: { status?: number } }).response?.status;
|
|
609
|
+
setError(status === 401 || status === 400 ? 'That email and password do not match.' : 'Sign-in failed. Check your connection and try again.');
|
|
610
|
+
} finally {
|
|
611
|
+
setBusy(false);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
|
|
617
|
+
<View style={styles.form}>
|
|
618
|
+
<Text style={styles.title}>Sign in</Text>
|
|
619
|
+
<Text style={styles.label}>Email</Text>
|
|
620
|
+
<TextInput style={styles.input} value={email} onChangeText={setEmail} autoCapitalize="none" autoComplete="email" keyboardType="email-address" textContentType="emailAddress" accessibilityLabel="Email" />
|
|
621
|
+
<Text style={styles.label}>Password</Text>
|
|
622
|
+
<TextInput style={styles.input} value={password} onChangeText={setPassword} secureTextEntry autoComplete="current-password" textContentType="password" accessibilityLabel="Password" onSubmitEditing={submit} />
|
|
623
|
+
{error ? <Text style={styles.error} accessibilityLiveRegion="polite">{error}</Text> : null}
|
|
624
|
+
<Pressable accessibilityRole="button" disabled={busy} onPress={submit} style={[styles.button, busy && styles.busy]}>
|
|
625
|
+
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Sign in</Text>}
|
|
626
|
+
</Pressable>
|
|
627
|
+
</View>
|
|
628
|
+
</KeyboardAvoidingView>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const styles = StyleSheet.create({
|
|
633
|
+
screen: { flex: 1, justifyContent: 'center', padding: 24 },
|
|
634
|
+
form: { gap: 8, width: '100%', maxWidth: 420, alignSelf: 'center' },
|
|
635
|
+
title: { fontSize: 26, fontWeight: '700', marginBottom: 12 },
|
|
636
|
+
label: { fontSize: 14, fontWeight: '600', marginTop: 8 },
|
|
637
|
+
input: { minHeight: 48, borderWidth: 1, borderColor: '#ccc', borderRadius: 10, paddingHorizontal: 14, fontSize: 16 },
|
|
638
|
+
error: { color: '#b3261e', marginTop: 8 },
|
|
639
|
+
button: { minHeight: 48, borderRadius: 10, alignItems: 'center', justifyContent: 'center', backgroundColor: '#3f51b5', marginTop: 16 },
|
|
640
|
+
busy: { opacity: 0.7 },
|
|
641
|
+
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
|
642
|
+
});
|
|
643
|
+
`);
|
|
644
|
+
// Dependency.
|
|
645
|
+
const pkgText = ws.read('package.json');
|
|
646
|
+
const dep = expo ? ['expo-secure-store', '~14.0.0'] : ['react-native-keychain', '^9.2.2'];
|
|
647
|
+
if (pkgText) {
|
|
648
|
+
try {
|
|
649
|
+
const pkg = JSON.parse(pkgText);
|
|
650
|
+
if (!pkg.dependencies?.[dep[0]]) {
|
|
651
|
+
pkg.dependencies = Object.fromEntries(Object.entries({ ...(pkg.dependencies ?? {}), [dep[0]]: dep[1] }).sort(([a], [b]) => a.localeCompare(b)));
|
|
652
|
+
const indent = /\n(\s+)"/.exec(pkgText)?.[1] ?? ' ';
|
|
653
|
+
ws.write('package.json', `${JSON.stringify(pkg, null, indent)}\n`);
|
|
654
|
+
summary.push(`package.json: ${dep[0]} (run npm install${expo ? ', then npx expo install --fix' : ''})`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
warnings.push(`Add ${dep[0]} to package.json.`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
summary.push(`${f('tokenStore.ts')}, ${f('session.ts')}, ${f('AuthContext.tsx')}: secure token storage, sign-in calls, AuthProvider and useAuth()`, `${page}: the sign-in screen`, 'Show the login screen when useAuth().status is "signedOut" (for example, a conditional Stack.Screen).');
|
|
662
|
+
}
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Flutter
|
|
665
|
+
function flutterAuth(ws, map, page, o, summary, warnings) {
|
|
666
|
+
const c = map.conventions;
|
|
667
|
+
const pkg = c.packageName || 'app';
|
|
668
|
+
const dir = 'lib/core/auth';
|
|
669
|
+
const f = (name) => `${dir}/${name}`;
|
|
670
|
+
const uri = (file) => `package:${pkg}/${file.replace(/^lib\//, '')}`;
|
|
671
|
+
const router = c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx' ? 'getx' : c.router;
|
|
672
|
+
ws.write(f('token_storage.dart'), `import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
673
|
+
|
|
674
|
+
/// The API tokens, in the Keychain (iOS) or the Keystore (Android), with an in-memory copy.
|
|
675
|
+
class TokenStorage {
|
|
676
|
+
TokenStorage([FlutterSecureStorage? storage]) : _storage = storage ?? const FlutterSecureStorage();
|
|
677
|
+
|
|
678
|
+
final FlutterSecureStorage _storage;
|
|
679
|
+
String? _access;
|
|
680
|
+
bool _loaded = false;
|
|
681
|
+
|
|
682
|
+
static const _accessKey = 'access_token';
|
|
683
|
+
static const _refreshKey = 'refresh_token';
|
|
684
|
+
|
|
685
|
+
Future<String?> get accessToken async {
|
|
686
|
+
if (!_loaded) {
|
|
687
|
+
_access = await _storage.read(key: _accessKey);
|
|
688
|
+
_loaded = true;
|
|
689
|
+
}
|
|
690
|
+
return _access;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
Future<String?> get refreshToken => _storage.read(key: _refreshKey);
|
|
694
|
+
|
|
695
|
+
Future<void> save({required String access, String? refresh}) async {
|
|
696
|
+
_access = access;
|
|
697
|
+
_loaded = true;
|
|
698
|
+
await _storage.write(key: _accessKey, value: access);
|
|
699
|
+
if (refresh != null) await _storage.write(key: _refreshKey, value: refresh);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
Future<void> clear() async {
|
|
703
|
+
_access = null;
|
|
704
|
+
_loaded = true;
|
|
705
|
+
await _storage.delete(key: _accessKey);
|
|
706
|
+
await _storage.delete(key: _refreshKey);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
final tokenStorage = TokenStorage();
|
|
711
|
+
`);
|
|
712
|
+
ws.write(f('auth_service.dart'), `import 'package:dio/dio.dart';
|
|
713
|
+
import 'package:flutter/foundation.dart';
|
|
714
|
+
import '${uri(f('token_storage.dart'))}';
|
|
715
|
+
|
|
716
|
+
enum AuthStatus { unknown, signedOut, signedIn }
|
|
717
|
+
|
|
718
|
+
/// Signs in and out, and knows whether someone is signed in. The router listens to it.
|
|
719
|
+
/// Change the paths here if your API uses others.
|
|
720
|
+
class AuthService extends ChangeNotifier {
|
|
721
|
+
AuthService(this._tokens) {
|
|
722
|
+
_restore();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
final TokenStorage _tokens;
|
|
726
|
+
Dio? _dio;
|
|
727
|
+
AuthStatus status = AuthStatus.unknown;
|
|
728
|
+
Map<String, dynamic>? user;
|
|
729
|
+
|
|
730
|
+
/// Called by attachAuth() with the app's Dio, so sign-in uses the same base URL and settings.
|
|
731
|
+
void attach(Dio dio) => _dio = dio;
|
|
732
|
+
|
|
733
|
+
/// True once the app's Dio exists (attachAuth ran).
|
|
734
|
+
bool get attached => _dio != null;
|
|
735
|
+
|
|
736
|
+
Dio get _api => _dio ?? (throw StateError('Call attachAuth(dio) where the app creates its Dio before signing in.'));
|
|
737
|
+
|
|
738
|
+
Future<void> _restore() async {
|
|
739
|
+
status = await _tokens.accessToken == null ? AuthStatus.signedOut : AuthStatus.signedIn;
|
|
740
|
+
notifyListeners();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
Future<void> signIn(String email, String password) async {
|
|
744
|
+
final res = await _api.post<Map<String, dynamic>>(${dq(o.loginPath)}, data: {'email': email, 'password': password});
|
|
745
|
+
final data = res.data ?? const {};
|
|
746
|
+
final token = data[${dq(o.tokenField)}];
|
|
747
|
+
if (token is! String || token.isEmpty) throw StateError('The login answer has no ${o.tokenField} field.');
|
|
748
|
+
final refresh = data[${dq(o.refreshField)}];
|
|
749
|
+
await _tokens.save(access: token, refresh: refresh is String ? refresh : null);
|
|
750
|
+
try {
|
|
751
|
+
user = (await _api.get<Map<String, dynamic>>(${dq(o.mePath)})).data;
|
|
752
|
+
} on DioException {
|
|
753
|
+
user = null;
|
|
754
|
+
}
|
|
755
|
+
status = AuthStatus.signedIn;
|
|
756
|
+
notifyListeners();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
Future<void> signOut() async {
|
|
760
|
+
try {
|
|
761
|
+
await _dio?.post<void>(${dq(o.logoutPath)});
|
|
762
|
+
} on DioException {
|
|
763
|
+
// Signed out on this device either way.
|
|
764
|
+
}
|
|
765
|
+
await expire();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/// The session ended (a 401 that could not be refreshed).
|
|
769
|
+
Future<void> expire() async {
|
|
770
|
+
await _tokens.clear();
|
|
771
|
+
user = null;
|
|
772
|
+
status = AuthStatus.signedOut;
|
|
773
|
+
notifyListeners();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
final authService = AuthService(tokenStorage);
|
|
778
|
+
`);
|
|
779
|
+
const authPaths = [o.loginPath, ...(o.refreshPath ? [o.refreshPath] : [])];
|
|
780
|
+
ws.write(f('auth_interceptor.dart'), `import 'package:dio/dio.dart';
|
|
781
|
+
import '${uri(f('auth_service.dart'))}';
|
|
782
|
+
import '${uri(f('token_storage.dart'))}';
|
|
783
|
+
|
|
784
|
+
/// Adds sign-in to a Dio instance: call it once where the app creates its Dio.
|
|
785
|
+
void attachAuth(Dio dio) {
|
|
786
|
+
authService.attach(dio);
|
|
787
|
+
dio.interceptors.add(AuthInterceptor(${o.refreshPath ? 'dio' : ''}));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/// Sends "Authorization: Bearer <token>". On a 401 it ${o.refreshPath ? 'refreshes the token once and repeats the request; if that fails, ' : ''}the session ends.
|
|
791
|
+
class AuthInterceptor extends QueuedInterceptor {
|
|
792
|
+
${o.refreshPath ? ' AuthInterceptor(this._dio);\n\n /// Repeats the request after a token refresh.\n final Dio _dio;\n' : ''} static const _retried = 'auth_retried';
|
|
793
|
+
static const _authPaths = [${authPaths.map((p) => dq(p)).join(', ')}];
|
|
794
|
+
|
|
795
|
+
@override
|
|
796
|
+
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
|
797
|
+
final token = await tokenStorage.accessToken;
|
|
798
|
+
if (token != null) options.headers['Authorization'] = 'Bearer \$token';
|
|
799
|
+
handler.next(options);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
@override
|
|
803
|
+
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
|
804
|
+
final request = err.requestOptions;
|
|
805
|
+
if (err.response?.statusCode != 401 || _authPaths.any(request.path.startsWith) || request.extra[_retried] == true) {
|
|
806
|
+
return handler.next(err);
|
|
807
|
+
}
|
|
808
|
+
${o.refreshPath
|
|
809
|
+
? ` final refresh = await tokenStorage.refreshToken;
|
|
810
|
+
if (refresh != null) {
|
|
811
|
+
try {
|
|
812
|
+
final plain = Dio(BaseOptions(baseUrl: _dio.options.baseUrl, headers: {'Accept': 'application/json'}));
|
|
813
|
+
final res = await plain.post<Map<String, dynamic>>(${dq(o.refreshPath)}, data: {'refreshToken': refresh});
|
|
814
|
+
final token = res.data?[${dq(o.tokenField)}];
|
|
815
|
+
if (token is String) {
|
|
816
|
+
final next = res.data?[${dq(o.refreshField)}];
|
|
817
|
+
await tokenStorage.save(access: token, refresh: next is String ? next : refresh);
|
|
818
|
+
request.extra[_retried] = true;
|
|
819
|
+
return handler.resolve(await _dio.fetch<dynamic>(request));
|
|
820
|
+
}
|
|
821
|
+
} on DioException {
|
|
822
|
+
// Falls through to sign out.
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
`
|
|
826
|
+
: ''} await authService.expire();
|
|
827
|
+
handler.next(err);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
`);
|
|
831
|
+
// Attach to the app's Dio.
|
|
832
|
+
const net = c.networkFile ? ws.read(c.networkFile) : null;
|
|
833
|
+
let attached = false;
|
|
834
|
+
if (net && c.networkFile && !/attachAuth\(/.test(net)) {
|
|
835
|
+
let t = net;
|
|
836
|
+
if (/\n(\s*)return dio;/.test(t)) {
|
|
837
|
+
t = t.replace(/\n(\s*)return dio;/, '\n$1attachAuth(dio);\n$1return dio;');
|
|
838
|
+
attached = true;
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
const m = /\n(\s*)(final\s+)?(\w+)\s*=\s*Dio\(/.exec(t);
|
|
842
|
+
if (m) {
|
|
843
|
+
// `_dio = Dio(...)` in a constructor: attach right after the statement ends.
|
|
844
|
+
const start = m.index + m[0].length;
|
|
845
|
+
let depth = 1;
|
|
846
|
+
let i = start;
|
|
847
|
+
for (; i < t.length && depth; i++) {
|
|
848
|
+
if (t[i] === '(')
|
|
849
|
+
depth++;
|
|
850
|
+
else if (t[i] === ')')
|
|
851
|
+
depth--;
|
|
852
|
+
}
|
|
853
|
+
const semi = t.indexOf(';', i);
|
|
854
|
+
if (semi !== -1) {
|
|
855
|
+
t = `${t.slice(0, semi + 1)}\n${m[1]}attachAuth(${m[3]});${t.slice(semi + 1)}`;
|
|
856
|
+
attached = true;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (attached) {
|
|
861
|
+
const line = `import '${uri(f('auth_interceptor.dart'))}';`;
|
|
862
|
+
t = t.replace(/((?:^import [^\n]+\n)+)/m, (block) => `${block}${line}\n`);
|
|
863
|
+
ws.write(c.networkFile, t);
|
|
864
|
+
summary.push(`${c.networkFile}: attachAuth(dio) sends the token and handles 401`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (!attached)
|
|
868
|
+
warnings.push(`Call attachAuth(dio) (from ${f('auth_interceptor.dart')}) where the app creates its Dio; ${c.networkFile || 'the network file'} did not have a place codz recognises.`);
|
|
869
|
+
// The login page.
|
|
870
|
+
const pageClass = /class (\w+) extends/.exec(ws.read(page) ?? '')?.[1] ?? 'LoginPage';
|
|
871
|
+
const getxPages = router === 'getx' && c.routesFile && ws.read(c.routesFile) !== null && /\binitial\b/.test(ws.read(c.routesFile) ?? '') ? c.routesFile : '';
|
|
872
|
+
const done = router === 'go_router' ? "context.go('/')" : router === 'getx' ? (getxPages ? 'Get.offAllNamed(AppPages.initial)' : "Get.offAllNamed('/')") : "Navigator.of(context).pushReplacementNamed('/')";
|
|
873
|
+
const navImport = router === 'go_router' ? "import 'package:go_router/go_router.dart';\n" : router === 'getx' ? `import 'package:get/get.dart';\n${getxPages ? `import '${uri(getxPages)}';\n` : ''}` : '';
|
|
874
|
+
// The app's Dio is often created lazily (a provider, get_it); signing in needs it, so the page creates it when it is not there yet.
|
|
875
|
+
const netText = c.networkFile ? ws.read(c.networkFile) ?? '' : '';
|
|
876
|
+
const ensure = /\bDio createDio\(/.test(netText) && attached ? ` if (!authService.attached) createDio();\n` : '';
|
|
877
|
+
const ensureImport = ensure && c.networkFile ? `import '${uri(c.networkFile)}';\n` : '';
|
|
878
|
+
ws.write(page, `import 'package:dio/dio.dart';
|
|
879
|
+
import 'package:flutter/material.dart';
|
|
880
|
+
${navImport}${ensureImport}import '${uri(f('auth_service.dart'))}';
|
|
881
|
+
|
|
882
|
+
class ${pageClass} extends StatefulWidget {
|
|
883
|
+
const ${pageClass}({super.key});
|
|
884
|
+
|
|
885
|
+
@override
|
|
886
|
+
State<${pageClass}> createState() => _${pageClass}State();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
class _${pageClass}State extends State<${pageClass}> {
|
|
890
|
+
final _form = GlobalKey<FormState>();
|
|
891
|
+
final _email = TextEditingController();
|
|
892
|
+
final _password = TextEditingController();
|
|
893
|
+
bool _busy = false;
|
|
894
|
+
String? _error;
|
|
895
|
+
|
|
896
|
+
@override
|
|
897
|
+
void dispose() {
|
|
898
|
+
_email.dispose();
|
|
899
|
+
_password.dispose();
|
|
900
|
+
super.dispose();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
Future<void> _submit() async {
|
|
904
|
+
if (!_form.currentState!.validate()) return;
|
|
905
|
+
setState(() {
|
|
906
|
+
_busy = true;
|
|
907
|
+
_error = null;
|
|
908
|
+
});
|
|
909
|
+
try {
|
|
910
|
+
${ensure} await authService.signIn(_email.text.trim(), _password.text);
|
|
911
|
+
if (mounted) ${done};
|
|
912
|
+
} on DioException catch (e) {
|
|
913
|
+
final status = e.response?.statusCode;
|
|
914
|
+
setState(() => _error = status == 401 || status == 400 ? 'That email and password do not match.' : 'Sign-in failed. Check your connection and try again.');
|
|
915
|
+
} catch (_) {
|
|
916
|
+
setState(() => _error = 'Sign-in failed. Try again.');
|
|
917
|
+
} finally {
|
|
918
|
+
if (mounted) setState(() => _busy = false);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
@override
|
|
923
|
+
Widget build(BuildContext context) {
|
|
924
|
+
return Scaffold(
|
|
925
|
+
appBar: AppBar(title: const Text('Sign in')),
|
|
926
|
+
body: Center(
|
|
927
|
+
child: SingleChildScrollView(
|
|
928
|
+
padding: const EdgeInsets.all(24),
|
|
929
|
+
child: ConstrainedBox(
|
|
930
|
+
constraints: const BoxConstraints(maxWidth: 420),
|
|
931
|
+
child: Form(
|
|
932
|
+
key: _form,
|
|
933
|
+
child: Column(
|
|
934
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
935
|
+
children: [
|
|
936
|
+
TextFormField(
|
|
937
|
+
controller: _email,
|
|
938
|
+
decoration: const InputDecoration(labelText: 'Email'),
|
|
939
|
+
keyboardType: TextInputType.emailAddress,
|
|
940
|
+
autofillHints: const [AutofillHints.email],
|
|
941
|
+
validator: (v) => (v == null || !v.contains('@')) ? 'Enter your email address.' : null,
|
|
942
|
+
),
|
|
943
|
+
const SizedBox(height: 16),
|
|
944
|
+
TextFormField(
|
|
945
|
+
controller: _password,
|
|
946
|
+
decoration: const InputDecoration(labelText: 'Password'),
|
|
947
|
+
obscureText: true,
|
|
948
|
+
autofillHints: const [AutofillHints.password],
|
|
949
|
+
onFieldSubmitted: (_) => _submit(),
|
|
950
|
+
validator: (v) => (v == null || v.isEmpty) ? 'Enter your password.' : null,
|
|
951
|
+
),
|
|
952
|
+
if (_error != null) ...[
|
|
953
|
+
const SizedBox(height: 16),
|
|
954
|
+
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
955
|
+
],
|
|
956
|
+
const SizedBox(height: 24),
|
|
957
|
+
FilledButton(
|
|
958
|
+
onPressed: _busy ? null : _submit,
|
|
959
|
+
child: _busy ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sign in'),
|
|
960
|
+
),
|
|
961
|
+
],
|
|
962
|
+
),
|
|
963
|
+
),
|
|
964
|
+
),
|
|
965
|
+
),
|
|
966
|
+
),
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
`);
|
|
971
|
+
// go_router: signed-out visitors go to /login.
|
|
972
|
+
if (router === 'go_router') {
|
|
973
|
+
ws.write(f('auth_redirect.dart'), `import 'package:flutter/widgets.dart';
|
|
974
|
+
import 'package:go_router/go_router.dart';
|
|
975
|
+
import '${uri(f('auth_service.dart'))}';
|
|
976
|
+
|
|
977
|
+
/// Pages anyone may open without signing in.
|
|
978
|
+
const publicPaths = ['/login'];
|
|
979
|
+
|
|
980
|
+
/// go_router redirect: signed-out visitors go to /login; signed-in ones never see it.
|
|
981
|
+
String? authRedirect(BuildContext context, GoRouterState state) {
|
|
982
|
+
if (authService.status == AuthStatus.unknown) return null;
|
|
983
|
+
final atPublic = publicPaths.contains(state.matchedLocation);
|
|
984
|
+
if (authService.status == AuthStatus.signedOut) return atPublic ? null : '/login';
|
|
985
|
+
return state.matchedLocation == '/login' ? '/' : null;
|
|
986
|
+
}
|
|
987
|
+
`);
|
|
988
|
+
const routerFile = findGoRouterFile(ws, c);
|
|
989
|
+
const rt = routerFile ? ws.read(routerFile) : null;
|
|
990
|
+
if (routerFile && rt && /GoRouter\(\s*\n/.test(rt) && !/authRedirect/.test(rt)) {
|
|
991
|
+
let t = rt.replace(/GoRouter\(\s*\n(\s*)/, (_m, indent) => `GoRouter(\n${indent}refreshListenable: authService,\n${indent}redirect: authRedirect,\n${indent}`);
|
|
992
|
+
t = t.replace(/((?:^import [^\n]+\n)+)/m, (block) => `${block}import '${uri(f('auth_redirect.dart'))}';\nimport '${uri(f('auth_service.dart'))}';\n`);
|
|
993
|
+
ws.write(routerFile, t);
|
|
994
|
+
summary.push(`${routerFile}: go_router sends signed-out visitors to /login`);
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
warnings.push(`Add refreshListenable: authService and redirect: authRedirect (from ${f('auth_redirect.dart')}) to your GoRouter.`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
warnings.push('This project does not use go_router, so pages are not guarded automatically: check authService.status before opening a page that needs sign-in.');
|
|
1002
|
+
}
|
|
1003
|
+
const pub = ws.read('pubspec.yaml');
|
|
1004
|
+
if (pub) {
|
|
1005
|
+
const r = addPubspecDeps(pub, ['flutter_secure_storage']);
|
|
1006
|
+
if (r.added.length) {
|
|
1007
|
+
ws.write('pubspec.yaml', r.text.replace(/flutter_secure_storage: any/, 'flutter_secure_storage: ^9.2.2'));
|
|
1008
|
+
summary.push('pubspec.yaml: flutter_secure_storage (run flutter pub get)');
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
summary.push(`${f('token_storage.dart')}, ${f('auth_service.dart')}, ${f('auth_interceptor.dart')}: secure tokens, AuthService (signIn, signOut, status), the Dio interceptor`, `${page}: the sign-in page`);
|
|
1012
|
+
}
|
|
1013
|
+
function findGoRouterFile(ws, c) {
|
|
1014
|
+
const candidates = [c.routesFile, 'lib/app/router.dart', 'lib/router.dart', 'lib/app_router.dart', 'lib/routes.dart', 'lib/main.dart', 'lib/app.dart'].filter((x) => !!x);
|
|
1015
|
+
return candidates.find((p) => /GoRouter\(/.test(ws.read(p) ?? ''));
|
|
1016
|
+
}
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
// Android
|
|
1019
|
+
function androidAuth(ws, map, page, o, summary, warnings) {
|
|
1020
|
+
const c = map.conventions;
|
|
1021
|
+
if (c.di !== 'hilt')
|
|
1022
|
+
throw new ToolError('not_supported', `auth add for Android needs Hilt (this project uses ${c.di || 'no DI library'}).`, 'Koin and manual DI are planned; for now add an OkHttp interceptor that sends the token by hand.');
|
|
1023
|
+
const pkg = c.namespace;
|
|
1024
|
+
if (!pkg || !c.srcDir)
|
|
1025
|
+
throw new ToolError('no_conventions', 'The app package or source folder is unknown.', 'Run codz init first.');
|
|
1026
|
+
const base = `${c.srcDir}/${pkg.replace(/\./g, '/')}`;
|
|
1027
|
+
const dir = `${base}/data/auth`;
|
|
1028
|
+
const f = (name) => `${dir}/${name}`;
|
|
1029
|
+
ws.write(f('TokenStore.kt'), `package ${pkg}.data.auth
|
|
1030
|
+
|
|
1031
|
+
import android.content.Context
|
|
1032
|
+
import androidx.security.crypto.EncryptedSharedPreferences
|
|
1033
|
+
import androidx.security.crypto.MasterKey
|
|
1034
|
+
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
1035
|
+
import javax.inject.Inject
|
|
1036
|
+
import javax.inject.Singleton
|
|
1037
|
+
|
|
1038
|
+
/** The API tokens, encrypted with a key from the Android Keystore. */
|
|
1039
|
+
@Singleton
|
|
1040
|
+
class TokenStore @Inject constructor(@ApplicationContext context: Context) {
|
|
1041
|
+
private val prefs = EncryptedSharedPreferences.create(
|
|
1042
|
+
context,
|
|
1043
|
+
"auth",
|
|
1044
|
+
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
|
1045
|
+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
1046
|
+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
var accessToken: String?
|
|
1050
|
+
get() = prefs.getString("access_token", null)
|
|
1051
|
+
set(value) = prefs.edit().putString("access_token", value).apply()
|
|
1052
|
+
|
|
1053
|
+
fun clear() = prefs.edit().clear().apply()
|
|
1054
|
+
}
|
|
1055
|
+
`);
|
|
1056
|
+
ws.write(f('AuthInterceptor.kt'), `package ${pkg}.data.auth
|
|
1057
|
+
|
|
1058
|
+
import javax.inject.Inject
|
|
1059
|
+
import javax.inject.Singleton
|
|
1060
|
+
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
1061
|
+
import kotlinx.coroutines.flow.SharedFlow
|
|
1062
|
+
import okhttp3.Interceptor
|
|
1063
|
+
import okhttp3.Response
|
|
1064
|
+
|
|
1065
|
+
/** Sends "Authorization: Bearer <token>"; a 401 clears the token and tells the app the session ended. */
|
|
1066
|
+
@Singleton
|
|
1067
|
+
class AuthInterceptor @Inject constructor(private val tokens: TokenStore) : Interceptor {
|
|
1068
|
+
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
|
1069
|
+
val sessionExpired: SharedFlow<Unit> = _sessionExpired
|
|
1070
|
+
|
|
1071
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
1072
|
+
val token = tokens.accessToken
|
|
1073
|
+
val request = if (token != null) chain.request().newBuilder().header("Authorization", "Bearer $token").build() else chain.request()
|
|
1074
|
+
val response = chain.proceed(request)
|
|
1075
|
+
if (response.code == 401 && !request.url.encodedPath.endsWith(${ktString(o.loginPath.replace(/^\//, ''))})) {
|
|
1076
|
+
tokens.clear()
|
|
1077
|
+
_sessionExpired.tryEmit(Unit)
|
|
1078
|
+
}
|
|
1079
|
+
return response
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
`);
|
|
1083
|
+
ws.write(f('AuthApi.kt'), `package ${pkg}.data.auth
|
|
1084
|
+
|
|
1085
|
+
import kotlinx.serialization.SerialName
|
|
1086
|
+
import kotlinx.serialization.Serializable
|
|
1087
|
+
import kotlinx.serialization.json.JsonObject
|
|
1088
|
+
import retrofit2.http.Body
|
|
1089
|
+
import retrofit2.http.GET
|
|
1090
|
+
import retrofit2.http.POST
|
|
1091
|
+
|
|
1092
|
+
@Serializable
|
|
1093
|
+
data class LoginRequest(val email: String, val password: String)
|
|
1094
|
+
|
|
1095
|
+
@Serializable
|
|
1096
|
+
data class LoginResponse(@SerialName(${ktString(o.tokenField)}) val token: String)
|
|
1097
|
+
|
|
1098
|
+
/** The sign-in endpoints. Change the paths here if your API uses others. */
|
|
1099
|
+
interface AuthApi {
|
|
1100
|
+
@POST(${ktString(o.loginPath.replace(/^\//, ''))})
|
|
1101
|
+
suspend fun login(@Body body: LoginRequest): LoginResponse
|
|
1102
|
+
|
|
1103
|
+
@GET(${ktString(o.mePath.replace(/^\//, ''))})
|
|
1104
|
+
suspend fun me(): JsonObject
|
|
1105
|
+
|
|
1106
|
+
@POST(${ktString(o.logoutPath.replace(/^\//, ''))})
|
|
1107
|
+
suspend fun logout()
|
|
1108
|
+
}
|
|
1109
|
+
`);
|
|
1110
|
+
ws.write(f('AuthRepository.kt'), `package ${pkg}.data.auth
|
|
1111
|
+
|
|
1112
|
+
import javax.inject.Inject
|
|
1113
|
+
import javax.inject.Singleton
|
|
1114
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
1115
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
1116
|
+
|
|
1117
|
+
@Singleton
|
|
1118
|
+
class AuthRepository @Inject constructor(
|
|
1119
|
+
private val api: AuthApi,
|
|
1120
|
+
private val tokens: TokenStore,
|
|
1121
|
+
) {
|
|
1122
|
+
private val _signedIn = MutableStateFlow(tokens.accessToken != null)
|
|
1123
|
+
val signedIn: StateFlow<Boolean> = _signedIn
|
|
1124
|
+
|
|
1125
|
+
suspend fun signIn(email: String, password: String) {
|
|
1126
|
+
tokens.accessToken = api.login(LoginRequest(email, password)).token
|
|
1127
|
+
_signedIn.value = true
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
suspend fun signOut() {
|
|
1131
|
+
runCatching { api.logout() }
|
|
1132
|
+
tokens.clear()
|
|
1133
|
+
_signedIn.value = false
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
fun sessionEnded() {
|
|
1137
|
+
tokens.clear()
|
|
1138
|
+
_signedIn.value = false
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
`);
|
|
1142
|
+
// NetworkModule: the interceptor on OkHttp, and a provider for AuthApi.
|
|
1143
|
+
const di = c.diFile ? ws.read(c.diFile) : null;
|
|
1144
|
+
if (di && c.diFile && /fun provideOkHttpClient\(\)/.test(di) && /OkHttpClient\.Builder\(\)/.test(di)) {
|
|
1145
|
+
let t = di.replace(/fun provideOkHttpClient\(\)/, 'fun provideOkHttpClient(authInterceptor: AuthInterceptor)');
|
|
1146
|
+
t = t.replace(/OkHttpClient\.Builder\(\)/, 'OkHttpClient.Builder()\n .addInterceptor(authInterceptor)');
|
|
1147
|
+
if (!/provideAuthApi/.test(t)) {
|
|
1148
|
+
const end = t.lastIndexOf('}');
|
|
1149
|
+
t = `${t.slice(0, end).replace(/\s*$/, '\n')}\n @Provides\n @Singleton\n fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)\n}\n`;
|
|
1150
|
+
}
|
|
1151
|
+
t = t.replace(/(\nimport [^\n]+)(?![\s\S]*\nimport )/, `$1\nimport ${pkg}.data.auth.AuthApi\nimport ${pkg}.data.auth.AuthInterceptor`);
|
|
1152
|
+
t = sortKotlinImports(t);
|
|
1153
|
+
ws.write(c.diFile, t);
|
|
1154
|
+
summary.push(`${c.diFile}: AuthInterceptor on OkHttp, and AuthApi`);
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
warnings.push(`Add AuthInterceptor to your OkHttpClient and provide AuthApi in ${c.diFile || 'your Hilt module'}; it does not have the shape codz writes.`);
|
|
1158
|
+
}
|
|
1159
|
+
// The login screen and its ViewModel.
|
|
1160
|
+
const screenName = /fun (\w+)\(/.exec(ws.read(page) ?? '')?.[1] ?? 'LoginScreen';
|
|
1161
|
+
const screenPkg = /^package ([\w.]+)/m.exec(ws.read(page) ?? '')?.[1] ?? `${pkg}.ui.screens`;
|
|
1162
|
+
ws.write(page, `package ${screenPkg}
|
|
1163
|
+
|
|
1164
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
1165
|
+
import androidx.compose.foundation.layout.Column
|
|
1166
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
1167
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
1168
|
+
import androidx.compose.foundation.layout.padding
|
|
1169
|
+
import androidx.compose.foundation.text.KeyboardOptions
|
|
1170
|
+
import androidx.compose.material3.Button
|
|
1171
|
+
import androidx.compose.material3.CircularProgressIndicator
|
|
1172
|
+
import androidx.compose.material3.MaterialTheme
|
|
1173
|
+
import androidx.compose.material3.OutlinedTextField
|
|
1174
|
+
import androidx.compose.material3.Scaffold
|
|
1175
|
+
import androidx.compose.material3.Text
|
|
1176
|
+
import androidx.compose.runtime.Composable
|
|
1177
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
1178
|
+
import androidx.compose.runtime.collectAsState
|
|
1179
|
+
import androidx.compose.runtime.getValue
|
|
1180
|
+
import androidx.compose.ui.Alignment
|
|
1181
|
+
import androidx.compose.ui.Modifier
|
|
1182
|
+
import androidx.compose.ui.text.input.KeyboardType
|
|
1183
|
+
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
1184
|
+
import androidx.compose.ui.unit.dp
|
|
1185
|
+
import androidx.hilt.navigation.compose.hiltViewModel
|
|
1186
|
+
|
|
1187
|
+
@Composable
|
|
1188
|
+
fun ${screenName}(onSignedIn: () -> Unit = {}, viewModel: LoginViewModel = hiltViewModel()) {
|
|
1189
|
+
val state by viewModel.state.collectAsState()
|
|
1190
|
+
LaunchedEffect(state.signedIn) { if (state.signedIn) onSignedIn() }
|
|
1191
|
+
Scaffold { padding ->
|
|
1192
|
+
Column(
|
|
1193
|
+
Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
|
1194
|
+
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
|
1195
|
+
) {
|
|
1196
|
+
Text("Sign in", style = MaterialTheme.typography.headlineMedium)
|
|
1197
|
+
OutlinedTextField(
|
|
1198
|
+
value = state.email,
|
|
1199
|
+
onValueChange = viewModel::onEmail,
|
|
1200
|
+
label = { Text("Email") },
|
|
1201
|
+
singleLine = true,
|
|
1202
|
+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
|
1203
|
+
modifier = Modifier.fillMaxWidth(),
|
|
1204
|
+
)
|
|
1205
|
+
OutlinedTextField(
|
|
1206
|
+
value = state.password,
|
|
1207
|
+
onValueChange = viewModel::onPassword,
|
|
1208
|
+
label = { Text("Password") },
|
|
1209
|
+
singleLine = true,
|
|
1210
|
+
visualTransformation = PasswordVisualTransformation(),
|
|
1211
|
+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
|
1212
|
+
modifier = Modifier.fillMaxWidth(),
|
|
1213
|
+
)
|
|
1214
|
+
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
1215
|
+
Button(onClick = viewModel::submit, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) {
|
|
1216
|
+
if (state.busy) CircularProgressIndicator(Modifier.padding(2.dp)) else Text("Sign in")
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
`);
|
|
1222
|
+
const vmFile = posix.join(posix.dirname(page), 'LoginViewModel.kt');
|
|
1223
|
+
ws.write(vmFile, `package ${screenPkg}
|
|
1224
|
+
|
|
1225
|
+
import androidx.lifecycle.ViewModel
|
|
1226
|
+
import androidx.lifecycle.viewModelScope
|
|
1227
|
+
import ${pkg}.data.auth.AuthRepository
|
|
1228
|
+
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
1229
|
+
import javax.inject.Inject
|
|
1230
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
1231
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
1232
|
+
import kotlinx.coroutines.flow.update
|
|
1233
|
+
import kotlinx.coroutines.launch
|
|
1234
|
+
import retrofit2.HttpException
|
|
1235
|
+
|
|
1236
|
+
data class LoginState(
|
|
1237
|
+
val email: String = "",
|
|
1238
|
+
val password: String = "",
|
|
1239
|
+
val busy: Boolean = false,
|
|
1240
|
+
val error: String? = null,
|
|
1241
|
+
val signedIn: Boolean = false,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
@HiltViewModel
|
|
1245
|
+
class LoginViewModel @Inject constructor(private val auth: AuthRepository) : ViewModel() {
|
|
1246
|
+
private val _state = MutableStateFlow(LoginState())
|
|
1247
|
+
val state: StateFlow<LoginState> = _state
|
|
1248
|
+
|
|
1249
|
+
fun onEmail(value: String) = _state.update { it.copy(email = value, error = null) }
|
|
1250
|
+
|
|
1251
|
+
fun onPassword(value: String) = _state.update { it.copy(password = value, error = null) }
|
|
1252
|
+
|
|
1253
|
+
fun submit() {
|
|
1254
|
+
val s = _state.value
|
|
1255
|
+
if (!s.email.contains('@') || s.password.isEmpty()) {
|
|
1256
|
+
_state.update { it.copy(error = "Enter your email and password.") }
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
_state.update { it.copy(busy = true, error = null) }
|
|
1260
|
+
viewModelScope.launch {
|
|
1261
|
+
try {
|
|
1262
|
+
auth.signIn(s.email.trim(), s.password)
|
|
1263
|
+
_state.update { it.copy(busy = false, signedIn = true) }
|
|
1264
|
+
} catch (e: HttpException) {
|
|
1265
|
+
val message = if (e.code() == 401 || e.code() == 400) "That email and password do not match." else "Sign-in failed (\${e.code()})."
|
|
1266
|
+
_state.update { it.copy(busy = false, error = message) }
|
|
1267
|
+
} catch (e: Exception) {
|
|
1268
|
+
_state.update { it.copy(busy = false, error = "Sign-in failed. Check your connection and try again.") }
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
`);
|
|
1274
|
+
// NavHost: after sign-in, go to the start screen and drop the login screen from the back stack.
|
|
1275
|
+
const nav = c.navFile ? ws.read(c.navFile) : null;
|
|
1276
|
+
if (nav && c.navFile) {
|
|
1277
|
+
const start = /startDestination = "([^"]+)"/.exec(nav)?.[1];
|
|
1278
|
+
const t = nav.replace(new RegExp(`(\\n\\s*)${screenName}\\(\\)`), (_m, lead) => `${lead}${screenName}(onSignedIn = { navController.navigate("${start && start !== 'login' ? start : 'home'}") { popUpTo("login") { inclusive = true } } })`);
|
|
1279
|
+
if (t !== nav)
|
|
1280
|
+
ws.write(c.navFile, t);
|
|
1281
|
+
}
|
|
1282
|
+
// Dependency: androidx.security:security-crypto.
|
|
1283
|
+
const catalogPath = c.versionCatalog;
|
|
1284
|
+
const gradlePath = `${c.module || 'app'}/build.gradle.kts`;
|
|
1285
|
+
const gradle = ws.read(gradlePath);
|
|
1286
|
+
if (catalogPath && ws.read(catalogPath) !== null && gradle) {
|
|
1287
|
+
let toml = ws.read(catalogPath);
|
|
1288
|
+
toml = tomlAdd(toml, 'versions', [['security-crypto', '"1.1.0-alpha06"']]);
|
|
1289
|
+
toml = tomlAdd(toml, 'libraries', [['androidx-security-crypto', '{ group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }']]);
|
|
1290
|
+
ws.write(catalogPath, toml);
|
|
1291
|
+
ws.write(gradlePath, gradleBlockAdd(gradle, 'dependencies', ['implementation(libs.androidx.security.crypto)']));
|
|
1292
|
+
summary.push(`${catalogPath} and ${gradlePath}: androidx.security:security-crypto`);
|
|
1293
|
+
}
|
|
1294
|
+
else if (gradle) {
|
|
1295
|
+
ws.write(gradlePath, gradleBlockAdd(gradle, 'dependencies', ['implementation("androidx.security:security-crypto:1.1.0-alpha06")']));
|
|
1296
|
+
}
|
|
1297
|
+
summary.push(`${dir}: TokenStore (encrypted), AuthInterceptor, AuthApi, AuthRepository (signIn, signOut, signedIn)`, `${page} and ${vmFile}: the sign-in screen`, 'Collect AuthInterceptor.sessionExpired (or AuthRepository.signedIn) to send the user to the login screen when the session ends.');
|
|
1298
|
+
}
|
|
1299
|
+
function sortKotlinImports(text) {
|
|
1300
|
+
return text.replace(/((?:^import [^\n]+\n)+)/m, (block) => {
|
|
1301
|
+
const lines = [...new Set(block.trim().split('\n'))].sort();
|
|
1302
|
+
return `${lines.join('\n')}\n`;
|
|
1303
|
+
});
|
|
1304
|
+
}
|