feat: GitHub OAuth + encrypted CF token storage + settings page + fix input theme
This commit is contained in:
parent
326e0e266e
commit
18b4bb853d
10
.env.example
10
.env.example
@ -12,3 +12,13 @@ CLOUDFLARE_API_TOKEN=your_api_token_here
|
|||||||
# (Create one in Zero Trust > Settings > Service Auth)
|
# (Create one in Zero Trust > Settings > Service Auth)
|
||||||
CF_SERVICE_CLIENT_ID=
|
CF_SERVICE_CLIENT_ID=
|
||||||
CF_SERVICE_CLIENT_SECRET=
|
CF_SERVICE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# GitHub OAuth (create at https://github.com/settings/developers)
|
||||||
|
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Required for next-auth session encryption (run: openssl rand -base64 32)
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
# Optional on Vercel — set to your production URL if self-hosting
|
||||||
|
# NEXTAUTH_URL=https://your-domain.vercel.app
|
||||||
|
|||||||
172
package-lock.json
generated
172
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"next": "^15.3.1",
|
"next": "^15.3.1",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
@ -37,6 +38,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||||
@ -697,6 +707,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -1033,6 +1052,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@ -1351,6 +1379,18 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.487.0",
|
"version": "0.487.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz",
|
||||||
@ -1440,6 +1480,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz",
|
||||||
|
"integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.3",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-auth/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next-themes": {
|
"node_modules/next-themes": {
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
@ -1478,6 +1559,54 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -1513,6 +1642,34 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
|
||||||
|
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
@ -1677,6 +1834,21 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,8 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"jose": "^5.10.0"
|
"jose": "^5.10.0",
|
||||||
|
"next-auth": "^4.24.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
|
|||||||
5
src/app/api/auth/[...nextauth]/route.ts
Normal file
5
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/authOptions";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
30
src/app/api/settings/route.ts
Normal file
30
src/app/api/settings/route.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/authOptions";
|
||||||
|
import { saveCFCreds, loadCFCreds } from "@/lib/credentials";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const creds = await loadCFCreds();
|
||||||
|
if (!creds) return NextResponse.json({ configured: false });
|
||||||
|
return NextResponse.json({
|
||||||
|
configured: true,
|
||||||
|
accountId: creds.accountId,
|
||||||
|
apiTokenMasked: `${creds.apiToken.slice(0, 6)}${"*".repeat(20)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { accountId, apiToken } = await req.json();
|
||||||
|
if (!accountId?.trim() || !apiToken?.trim()) {
|
||||||
|
return NextResponse.json({ error: "accountId and apiToken are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveCFCreds({ accountId: accountId.trim(), apiToken: apiToken.trim() });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@ -306,20 +306,20 @@ export default function CreatePage() {
|
|||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
border: 1px solid rgb(39 39 42);
|
border: 1px solid var(--border);
|
||||||
background: rgb(24 24 27 / 0.8);
|
background: var(--bg-subtle);
|
||||||
padding: 0.625rem 0.875rem;
|
padding: 0.625rem 0.875rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: rgb(228 228 231);
|
color: var(--text);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.form-input:focus {
|
.form-input:focus {
|
||||||
border-color: rgb(16 185 129 / 0.5);
|
border-color: var(--accent-border);
|
||||||
box-shadow: 0 0 0 2px rgb(16 185 129 / 0.1);
|
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||||
}
|
}
|
||||||
.form-input::placeholder {
|
.form-input::placeholder {
|
||||||
color: rgb(113 113 122);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
src/app/login/page.tsx
Normal file
32
src/app/login/page.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { Monitor, Github } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-3.5rem)] flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="rounded-2xl border border-[var(--border)] bg-[var(--bg-card)] p-8 text-center shadow-xl">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[var(--accent-dim)] border border-[var(--accent-border)] flex items-center justify-center mx-auto mb-5">
|
||||||
|
<Monitor className="w-6 h-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text)] mb-1">SSH Launcher</h1>
|
||||||
|
<p className="text-sm text-[var(--text-muted)] mb-8">
|
||||||
|
Sign in to manage your Cloudflare Zero Trust SSH hosts
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => signIn("github", { callbackUrl: "/" })}
|
||||||
|
className="w-full flex items-center justify-center gap-3 rounded-xl bg-[var(--text)] text-[var(--bg)] font-semibold py-3 px-4 hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
<Github className="w-4 h-4" />
|
||||||
|
Continue with GitHub
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-[var(--text-faint)] mt-6">
|
||||||
|
Your Cloudflare credentials are stored encrypted in your browser session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<SessionProvider>
|
||||||
{children}
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
</ThemeProvider>
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
195
src/app/settings/page.tsx
Normal file
195
src/app/settings/page.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Settings, Key, Save, Check, AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
configured: boolean;
|
||||||
|
accountId?: string;
|
||||||
|
apiTokenMasked?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [state, setState] = useState<SettingsState | null>(null);
|
||||||
|
const [accountId, setAccountId] = useState("");
|
||||||
|
const [apiToken, setApiToken] = useState("");
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
setState(d);
|
||||||
|
if (d.accountId) setAccountId(d.accountId);
|
||||||
|
})
|
||||||
|
.catch(() => setState({ configured: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ accountId, apiToken }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
setSaved(true);
|
||||||
|
setState({ configured: true, accountId, apiTokenMasked: `${apiToken.slice(0, 6)}${"*".repeat(20)}` });
|
||||||
|
setApiToken("");
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-3.5rem)]">
|
||||||
|
<div className="max-w-xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--bg-card)] border border-[var(--border)] flex items-center justify-center">
|
||||||
|
<Settings className="w-5 h-5 text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text)]">Settings</h1>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">
|
||||||
|
Cloudflare API credentials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current status */}
|
||||||
|
{state && (
|
||||||
|
<div className={`rounded-xl border p-4 mb-6 flex items-start gap-3 ${
|
||||||
|
state.configured
|
||||||
|
? "border-emerald-500/20 bg-emerald-500/5"
|
||||||
|
: "border-amber-500/20 bg-amber-500/5"
|
||||||
|
}`}>
|
||||||
|
{state.configured ? (
|
||||||
|
<Check className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="text-sm min-w-0">
|
||||||
|
{state.configured ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-emerald-600 dark:text-emerald-400">Credentials configured</p>
|
||||||
|
<p className="text-[var(--text-faint)] mt-0.5 font-mono text-xs break-all">
|
||||||
|
Account: {state.accountId}<br />
|
||||||
|
Token: {state.apiTokenMasked}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-amber-600 dark:text-amber-400">Not configured</p>
|
||||||
|
<p className="text-[var(--text-faint)] mt-0.5 text-xs">
|
||||||
|
Enter your Cloudflare API credentials below. They'll be stored
|
||||||
|
encrypted in your session cookie (90 days).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSave} className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
|
||||||
|
<Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
Account ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accountId}
|
||||||
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
|
placeholder="Your Cloudflare Account ID"
|
||||||
|
className="settings-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-faint)] mt-1">
|
||||||
|
Found in Cloudflare Dashboard → right sidebar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
|
||||||
|
<Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
API Token
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showToken ? "text" : "password"}
|
||||||
|
value={apiToken}
|
||||||
|
onChange={(e) => setApiToken(e.target.value)}
|
||||||
|
placeholder={state?.configured ? "Enter new token to update" : "Your Cloudflare API Token"}
|
||||||
|
className="settings-input pr-10"
|
||||||
|
required={!state?.configured}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-faint)] hover:text-[var(--text-muted)]"
|
||||||
|
>
|
||||||
|
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-faint)] mt-1">
|
||||||
|
Needs: Access Write · Tunnel Edit · DNS Edit · Zone Read
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold py-2.5 transition"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<><RefreshCw className="w-4 h-4 animate-spin" /> Saving...</>
|
||||||
|
) : saved ? (
|
||||||
|
<><Check className="w-4 h-4" /> Saved!</>
|
||||||
|
) : (
|
||||||
|
<><Save className="w-4 h-4" /> Save Credentials</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.settings-input {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.settings-input:focus {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||||
|
}
|
||||||
|
.settings-input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,12 +4,14 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Monitor, PlusCircle, Server, Sun, Moon, User } from "lucide-react";
|
import { useSession, signIn, signOut } from "next-auth/react";
|
||||||
|
import { Monitor, PlusCircle, Server, Sun, Moon, Settings, LogOut, LogIn } from "lucide-react";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Hosts", icon: Monitor },
|
{ href: "/", label: "Hosts", icon: Monitor },
|
||||||
{ href: "/create", label: "Deploy", icon: PlusCircle },
|
{ href: "/create", label: "Deploy", icon: PlusCircle },
|
||||||
{ href: "/tunnels", label: "Tunnels", icon: Server },
|
{ href: "/tunnels", label: "Tunnels", icon: Server },
|
||||||
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
@ -28,6 +30,45 @@ function ThemeToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserWidget() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
if (status === "loading") return <div className="w-8 h-8" />;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => signIn("github")}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<LogIn className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Sign in</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{session.user?.image ? (
|
||||||
|
<img
|
||||||
|
src={session.user.image}
|
||||||
|
alt={session.user.name ?? ""}
|
||||||
|
className="w-7 h-7 rounded-full border border-[var(--border)]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="hidden sm:inline text-xs text-[var(--text-muted)] max-w-[120px] truncate">
|
||||||
|
{session.user?.name ?? session.user?.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-faint)] hover:text-red-500 hover:bg-[var(--bg-card)] transition"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Navigation() {
|
export default function Navigation() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
@ -66,10 +107,7 @@ export default function Navigation() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-[var(--bg-card)] border border-[var(--border)] text-xs text-[var(--text-muted)]">
|
<UserWidget />
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<span>CF Access</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
23
src/lib/authOptions.ts
Normal file
23
src/lib/authOptions.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { NextAuthOptions } from "next-auth";
|
||||||
|
import GithubProvider from "next-auth/providers/github";
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
GithubProvider({
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID ?? "",
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token.sub) {
|
||||||
|
(session.user as { id?: string }).id = token.sub;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -74,20 +74,15 @@ export interface SetupResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Credentials ─────────────────────────────────────────────────────────────
|
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||||
|
// Reads from env vars first, falls back to encrypted cookie set via /settings
|
||||||
|
import { requireCFCreds } from "@/lib/credentials";
|
||||||
|
|
||||||
function getCredentials() {
|
async function getCredentials() {
|
||||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
return requireCFCreds();
|
||||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
||||||
if (!accountId || !apiToken) {
|
|
||||||
throw new Error(
|
|
||||||
"Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN env vars"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { accountId, apiToken };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cfGet(path: string, revalidateSec = 60) {
|
async function cfGet(path: string, revalidateSec = 60) {
|
||||||
const { apiToken } = getCredentials();
|
const { apiToken } = await getCredentials();
|
||||||
const res = await fetch(`${CF_API_BASE}${path}`, {
|
const res = await fetch(`${CF_API_BASE}${path}`, {
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
next: { revalidate: revalidateSec },
|
next: { revalidate: revalidateSec },
|
||||||
@ -100,7 +95,7 @@ async function cfGet(path: string, revalidateSec = 60) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cfPost(path: string, body: unknown) {
|
async function cfPost(path: string, body: unknown) {
|
||||||
const { apiToken } = getCredentials();
|
const { apiToken } = await getCredentials();
|
||||||
const res = await fetch(`${CF_API_BASE}${path}`, {
|
const res = await fetch(`${CF_API_BASE}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -119,7 +114,7 @@ async function cfPost(path: string, body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cfPut(path: string, body: unknown) {
|
async function cfPut(path: string, body: unknown) {
|
||||||
const { apiToken } = getCredentials();
|
const { apiToken } = await getCredentials();
|
||||||
const res = await fetch(`${CF_API_BASE}${path}`, {
|
const res = await fetch(`${CF_API_BASE}${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@ -138,7 +133,7 @@ async function cfPut(path: string, body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cfDelete(path: string) {
|
async function cfDelete(path: string) {
|
||||||
const { apiToken } = getCredentials();
|
const { apiToken } = await getCredentials();
|
||||||
const res = await fetch(`${CF_API_BASE}${path}`, {
|
const res = await fetch(`${CF_API_BASE}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
@ -155,7 +150,7 @@ async function cfDelete(path: string) {
|
|||||||
// ─── Access Applications ─────────────────────────────────────────────────────
|
// ─── Access Applications ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listAccessApps(): Promise<AccessApp[]> {
|
export async function listAccessApps(): Promise<AccessApp[]> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
const allApps: AccessApp[] = [];
|
const allApps: AccessApp[] = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
const perPage = 50;
|
const perPage = 50;
|
||||||
@ -216,7 +211,7 @@ export async function getSshAppById(id: string): Promise<SshApp | null> {
|
|||||||
// ─── Tunnels ─────────────────────────────────────────────────────────────────
|
// ─── Tunnels ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listTunnels(): Promise<Tunnel[]> {
|
export async function listTunnels(): Promise<Tunnel[]> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
const json = await cfGet(
|
const json = await cfGet(
|
||||||
`/accounts/${accountId}/cfd_tunnel?is_deleted=false&per_page=50`,
|
`/accounts/${accountId}/cfd_tunnel?is_deleted=false&per_page=50`,
|
||||||
30
|
30
|
||||||
@ -225,7 +220,7 @@ export async function listTunnels(): Promise<Tunnel[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTunnel(name: string): Promise<Tunnel> {
|
export async function createTunnel(name: string): Promise<Tunnel> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
const tunnelSecret = Buffer.from(
|
const tunnelSecret = Buffer.from(
|
||||||
crypto.getRandomValues(new Uint8Array(32))
|
crypto.getRandomValues(new Uint8Array(32))
|
||||||
).toString("base64");
|
).toString("base64");
|
||||||
@ -238,12 +233,12 @@ export async function createTunnel(name: string): Promise<Tunnel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTunnel(tunnelId: string): Promise<void> {
|
export async function deleteTunnel(tunnelId: string): Promise<void> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
await cfDelete(`/accounts/${accountId}/cfd_tunnel/${tunnelId}`);
|
await cfDelete(`/accounts/${accountId}/cfd_tunnel/${tunnelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTunnelToken(tunnelId: string): Promise<string> {
|
export async function getTunnelToken(tunnelId: string): Promise<string> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
const json = await cfGet(
|
const json = await cfGet(
|
||||||
`/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`,
|
`/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`,
|
||||||
0
|
0
|
||||||
@ -256,7 +251,7 @@ export async function configureTunnel(
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
sshPort: number
|
sshPort: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
|
await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
|
||||||
config: {
|
config: {
|
||||||
ingress: [
|
ingress: [
|
||||||
@ -311,7 +306,7 @@ export async function createAccessApp(
|
|||||||
appName: string,
|
appName: string,
|
||||||
allowedEmails: string[]
|
allowedEmails: string[]
|
||||||
): Promise<{ appId: string; metricsHostname: string }> {
|
): Promise<{ appId: string; metricsHostname: string }> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
|
|
||||||
const policies = [];
|
const policies = [];
|
||||||
|
|
||||||
@ -353,7 +348,7 @@ export async function createAccessApp(
|
|||||||
export async function getOrCreateCaCert(
|
export async function getOrCreateCaCert(
|
||||||
appId: string
|
appId: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
try {
|
try {
|
||||||
// Try to get existing CA
|
// Try to get existing CA
|
||||||
const json = await cfGet(
|
const json = await cfGet(
|
||||||
@ -425,7 +420,7 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
|
|||||||
// Step 3: Configure tunnel ingress (SSH + metrics HTTP)
|
// Step 3: Configure tunnel ingress (SSH + metrics HTTP)
|
||||||
const metricsDomain = `metrics.${input.hostname}`;
|
const metricsDomain = `metrics.${input.hostname}`;
|
||||||
try {
|
try {
|
||||||
const { accountId } = getCredentials();
|
const { accountId } = await getCredentials();
|
||||||
await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
|
await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
|
||||||
config: {
|
config: {
|
||||||
ingress: [
|
ingress: [
|
||||||
|
|||||||
58
src/lib/credentials.ts
Normal file
58
src/lib/credentials.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { jwtDecrypt, EncryptJWT } from "jose";
|
||||||
|
|
||||||
|
export interface CFCreds {
|
||||||
|
accountId: string;
|
||||||
|
apiToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encKey(): Uint8Array {
|
||||||
|
const s = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? "dev-secret-change-me";
|
||||||
|
const buf = Buffer.from(s.padEnd(32, "0").slice(0, 32), "utf8");
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCFCreds(creds: CFCreds): Promise<void> {
|
||||||
|
const token = await new EncryptJWT({ ...creds })
|
||||||
|
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
||||||
|
.setExpirationTime("90d")
|
||||||
|
.encrypt(encKey());
|
||||||
|
|
||||||
|
const jar = await cookies();
|
||||||
|
jar.set("cf_creds", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 90,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCFCreds(): Promise<CFCreds | null> {
|
||||||
|
if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) {
|
||||||
|
return {
|
||||||
|
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jar = await cookies();
|
||||||
|
const raw = jar.get("cf_creds")?.value;
|
||||||
|
if (!raw) return null;
|
||||||
|
const { payload } = await jwtDecrypt(raw, encKey());
|
||||||
|
return {
|
||||||
|
accountId: payload.accountId as string,
|
||||||
|
apiToken: payload.apiToken as string,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireCFCreds(): Promise<CFCreds> {
|
||||||
|
const creds = await loadCFCreds();
|
||||||
|
if (!creds) {
|
||||||
|
throw new Error("Cloudflare credentials not configured. Visit /settings to set them up.");
|
||||||
|
}
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user