尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗An internal tool for submitting, reviewing, assigning, and controlling access to supplier account credentials ("IDs"). There are three kinds of user, each with their own sign-in and their own screens:
| Role | How the login is created | What they can do |
|---|---|---|
| Supplier | Signs up himself at /signup | Submit IDs; see his own dashboard — how many IDs he gave, and which were accepted or rejected (with the reason), day by day, and which IDs he has been paid for (with the date — never the amount). Only accepted IDs are paid. Never sees a stored password again, and never sees another supplier's IDs. |
| Media buyer | Added by an admin on Suppliers & buyers | See only the IDs assigned to him, with their login details (passwords, Outlook mail, temp mail, 2FA key) shown in plain text so he can check them, change a password / 2FA key if it changes, and mark each one active or rejected (a note saying why is required). The New today count and filter show which IDs he was given today. |
| Admin | Promoted by hand (see below) | See everything, credentials shown in plain text; change any ID's passwords / 2FA key; accept or reject IDs; assign IDs to a media buyer by name, one at a time or in bulk; add media buyers; see on which date each supplier gave how many IDs, pay for that day's accepted IDs (Mark paid with the amount), and see each month's IDs bought / pending / accepted / rejected / paid for and the total spent (dashboard and Supplier payments); export. |
Life of an ID: a supplier submits it (pending) → an admin accepts it, or assigns it to a media buyer, which also accepts it (accepted) → the media buyer checks it and marks it active or rejected. An admin can also reject, archive or reassign at any point. Credentials are shown in plain text on an ID's page, to an admin or the media buyer it is assigned to only; every time that page is opened it is freshly authorized and audit-logged.
Stack: Next.js 16 (App Router) + TypeScript + Tailwind CSS v4, Supabase (Postgres + Auth). Credentials are stored as plain text — no encryption key is needed (see Credential storage).
With no configuration at all, the app runs in demo mode: an in-memory, fictional data set with a demo admin, two demo media buyers and two demo suppliers. Nothing is persisted — it resets every time the server restarts — and this is loudly banner-labeled in the UI. The view/audit flow behaves like production, just without durability.
npm install
npm run dev
On Windows you can instead double-click start-app.bat, which runs those two commands
and opens the browser.
Open http://localhost:3000 — you'll land on the sign-in page. In demo mode, pick any of
the listed demo users to see that role's screens (a simulated sign-in, clearly marked as
such — see src/app/actions/auth.ts), or use Create an account to try the supplier
sign-up.
supabase/migrations/ in filename order:
0001_schema.sql — tables, triggers, the profiles role sync trigger.0002_rls.sql — Row Level Security policies. This is the real authorization
boundary; the app's server checks are a second layer, not a substitute.0003_secret_presence.sql — a narrow, presence-only RPC so the UI can show
"a value is stored" without ever exposing ciphertext through it.0004_two_factor_secret.sql — allows storing an encrypted 2FA key.0005_roles_assignment.sql — supplier / media buyer / admin roles, which supplier
an ID belongs to, which media buyer it is assigned to, the accepted / rejected
statuses and the rejection note. If your database already exists, this is the one
you still need to run. It is safe to run more than once; it renames the old team
role to supplier and moves any needs_review IDs back to pending.0006_buyer_source.sql — lets a media buyer add IDs of his own.0007_supplier_payments.sql — the Supplier payments page: which supplier was
paid for which day. Admin-only (RLS). Safe to run more than once.0008_paid_per_id.sql — only accepted IDs are paid; each paid ID gets its own
paid_at. Run it before deploying the version that uses it. Safe to run more
than once.supabase/seed.sql for fictional sample suppliers/accounts (no secrets —
attach fictional secret values afterward through the app UI)..env.example to .env.local and fill in:
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY — Project Settings → API.SUPABASE_SERVICE_ROLE_KEY — same page. Server-only. Never expose this to the
browser, never prefix with NEXT_PUBLIC_, never commit it.ENCRYPTION_KEYS — optional, only to read credentials saved before they were
stored as plain text; see the next section.npm run dev. The app detects the Supabase env vars and leaves demo mode automatically.New sign-ups are always the supplier role (enforced by the handle_new_user trigger and
by RLS — a user can never change their own role). Suppliers sign up at /signup; the
login is created by the server with the email already confirmed, so sign-up does not
depend on the Supabase project's email settings, and there is no email verification —
anyone who knows the address of the site can create a supplier account. Media buyers are
added by an admin inside the app. For the first admin, see
Creating the first admin.
Passwords, email passwords and 2FA keys are stored in account_secrets as plain text
(the value is in the ciphertext column, key_version = -1). No encryption key is needed
to save or read them. Access still goes only through the server: RLS denies all direct
client access to account_secrets, and every view or export is authorized and
audit-logged. But anyone who can read that table — a database backup, the Supabase
dashboard, or whoever holds SUPABASE_SERVICE_ROLE_KEY — can read every credential, so
protect those accordingly.
Older, encrypted rows. Credentials saved before this change are AES-256-GCM encrypted
(key_version >= 1). They can still be read as long as their key stays in
ENCRYPTION_KEYS (e.g. ENCRYPTION_KEYS={"1":"<key>"}); without it they show as
"could not decrypt". Changing such a value in the app re-saves it as plain text.
npm run check-key tells you whether a key opens them.
Converting them all at once. Deploy this version first (the older one cannot read
plain-text rows). Then, with NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY and
ENCRYPTION_KEYS in .env.local (or the environment):
npm run decrypt-secrets
This is a dry run: it checks that every encrypted row can be read and lists any that can't, without changing anything. Then convert them:
npm run decrypt-secrets -- --apply
It never prints a credential value, skips rows that are already plain text, leaves any
row it can't read as it is, and writes one secret.bulk_convert_plaintext audit entry.
Converted rows get a new updated_at. It is safe to run again. When it reports
Still encrypted: 0, remove ENCRYPTION_KEYS and ENCRYPTION_KEY_VERSION from every
environment and redeploy. Keep a private copy of the key for as long as you might restore
a database backup taken before the conversion.
There's no bootstrap UI for this on purpose (an admin-creation button reachable by anyone
would defeat the point). After a user has signed in at least once (so a profiles row
exists via the handle_new_user trigger), promote them from the Supabase SQL editor:
update public.profiles set role = 'admin' where email = '[email protected]';
profiles.role is the single source of truth for authorization, checked fresh on every
server action, route handler, and RLS policy — never cached from a JWT claim.profiles_update_self_limited
RLS policy enforces this at the database level, independent of the app code)./signup.supplier with the
same kind of SQL update (they immediately lose the media-buyer screens and can no longer
see any credential), and ban the user in Supabase → Authentication if they should not
be able to sign in at all. IDs that were assigned to them show "Former media buyer" in
the admin list until you reassign them..env.example's variables in your host's environment configuration (e.g.
Vercel → Project → Settings → Environment Variables). Never commit them.SUPABASE_SERVICE_ROLE_KEY and ENCRYPTION_KEYS are marked server-only /
"sensitive" if your host distinguishes that; they must never end up in a client bundle.npm run build && npm run start, or deploy via your platform's normal Next.js flow.supabase/migrations/0002_rls.sql.account_secrets denies all direct client access. The only way to reach it is the
server's service-role client inside src/lib/data/secrets.ts, which re-checks the
caller's authentication and role from scratch on every single call — view or write.
An admin can see any ID's credentials; a media buyer only those of an ID assigned to
him at that moment (unassigning cuts him off immediately); a supplier never.accounts at all. Their screens go
through server code (src/lib/data/accounts.ts) that checks the role and then filters
by supplier_id / assigned_to itself, using the id from the session — never one from
the URL or a form. Directly against the database, with their own login, they can read
nothing but their own profile row.getSecretsForViewer in src/lib/data/secrets.ts and
src/components/CredentialsList.tsx. Anyone looking at that screen can read them.secret.view) — actor, record, which fields were shown,
outcome — with the secret values themselves never included.changeSecret
(src/lib/data/secrets.ts). The new value is stored as plain text like any other, and every
attempt is audit-logged (secret.update) without the value.src/app/api/export/accounts/route.ts)
decrypts every password and 2FA key into the file, one sheet per day. Every export is
audit-logged and the filename contains — treat the downloaded file as
being as sensitive as the whole database.This template does not implement automatic retention/deletion — decide policy for your organization and enforce it operationally (e.g. a scheduled job) or ask for scoped follow-up work:
account_secrets rows.Works out of the box (demo mode, no setup):
Works once you configure Supabase (see setup above):
Needs your decision / follow-up work, not included by default:
src/
app/
actions/ Server Actions (auth, accounts, secrets, users) — the only write paths
api/export/ Admin-only Excel export (includes decrypted credentials)
admin/ Admin pages (dashboard, all IDs, suppliers & buyers)
buyer/ Media buyer pages (my IDs, check one ID)
supplier/ Supplier pages (my dashboard, submit an ID)
signup/ Supplier sign-up
login/ Sign-in for every role (real or simulated demo sign-in)
team/ Old links only — redirects to the supplier area
components/ Shared UI (CredentialsList, AccountForm, AccountTable, AssignSelect, RoleShell, ...)
lib/
crypto.ts credential storage format (plain text; reads old AES rows) — server-only
auth.ts getCurrentUser/requireUser/requireRole/requireAdmin — fresh check every call
data/ Data-access layer (accounts, profiles, secrets, audit)
demo/ In-memory demo-mode store (never used when Supabase is configured)
supabase/ RLS-bound client (server.ts) and service-role client (admin.ts)
supabase/
migrations/ Schema + RLS policies, run in order
seed.sql Fictional sample data (no secrets)
scripts/generate-key.mjs Prints a new base64 AES-256 key
scripts/decrypt-secrets.mjs One-time: converts old encrypted credentials to plain text
CREDENTIALSrequireAdmin()) and by the accounts_admin_update /
accounts_select_admin_only RLS policies.accounts
has no INSERT policy for anon/authenticated at all — the same "deny by default,
only the service-role server function can reach it" pattern this app already uses for
account_secrets and audit_log. See createAccount in src/lib/data/accounts.ts.