diff --git a/README.md b/README.md index bca2b55..79174dc 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,72 @@
-
- - -
-

Mocha x Supabase

-

Forked from cortado

-

A minimal password manager hand-made using Tauri, Rust, React + Supabase -
Your passwords stay on your machine, synced to your own cloud -

- - GitHub Repo stars + + + + + +

Mocha Suite

+

A personal suite of privacy tools — password manager, authenticator, subscriptions.

+

Built with Tauri, Rust, React + Supabase

+ + GitHub Repo stars
# Overview -Mocha is my take on a password manager that does exactly what it should and -nothing more : a very simple encrypted vault, protected by a master -password, synced to your own Supabase instance. Feel free to open an issue if you -find something I could potentially work on. +Mocha Suite is a desktop application that brings together the privacy tools you use +every day into one place. No cloud dependencies, no subscriptions — just your own +Supabase instance and your own data. + +**Apps included:** + +- **Password Manager** — AES-256-GCM encrypted vault synced to your Supabase storage, + protected by a master password (Argon2id key derivation) +- **Authenticator** — TOTP (RFC 6238) two-factor codes generated from base32 secrets, + stored securely in your Supabase storage +- **Subscriptions** — track recurring subscriptions with cost summaries, upcoming + renewals, and category management ## Features -- Master-password protected vault (AES-256-GCM encryption, key derived with Argon2id) -- **Cloud sync via Supabase** — your vault lives on your own Supabase project, accessible from any machine -- **Email/password authentication** — sign in or sign up to manage your vault -- Full entry management : name, username, password, URL, notes and folder, with - inline edit / move-to-folder / delete actions on every list entry -- Folders : organize entries and filter by folder in the sidebar, mapped to and - from Bitwarden folders on import/export -- Password generator (20 chars, letters/digits/symbols, OS cryptographic random source) -- Bitwarden-compatible JSON export & import (unencrypted format) -- Search across name, username and URL, combined with the active folder +### Password Manager +- Master-password protected vault (AES-256-GCM, key derived with Argon2id) +- Cloud sync via Supabase — your vault lives on your own Supabase project +- Full entry management : name, username, password, URL, notes, folders +- Folders with sidebar filtering (mapped to/from Bitwarden) +- Password generator (20 chars, cryptographic random source) +- Bitwarden-compatible JSON export & import +- Search across name, username and URL - Copy-to-clipboard and show/hide passwords + +### Authenticator +- TOTP code generation (RFC 6238, HMAC-SHA1, 6-digit, 30-second window) +- Live countdown bar showing time until next code refresh +- Manual secret entry (base32) +- Copy code to clipboard +- Persisted to Supabase storage + +### Subscriptions +- Full CRUD for recurring subscriptions +- Monthly / yearly / quarterly / weekly billing cycles +- Monthly and yearly spend totals +- Upcoming renewals (next 30 days) +- 66 popular subscription presets with search +- Color-coded category badges +- 20 supported currencies (including XPF) +- Persisted to Supabase storage + +### General +- Email/password authentication via Supabase Auth +- **6 themes** — Mocha (default), Latte, Espresso, Matcha, Midnight, Caramel +- **6 languages** — English, Français, Español, 中文, 한국어, Русский +- Persistent sidebar navigation +- In-app Supabase setup (no `.env` file required) - Tiny footprint : Tauri uses the OS webview, no bundled Chromium (~3–5 MB binary) -- **In-app setup** — configure your Supabase credentials directly from the UI, no `.env` file needed ## Setup your own instance -This section walks you through everything from zero to a running Mocha app on -your machine. Take your time — the Supabase part is the only non-trivial step. - ### 1. Prerequisites -You need three things installed on your machine: - | Tool | How to get it | |---|---| | **Node.js + npm** | Download from [nodejs.org](https://nodejs.org) (LTS version is fine) | @@ -69,189 +92,128 @@ You need three things installed on your machine: ### 2. Create a Supabase project (free) -Mocha uses [Supabase](https://supabase.com) to store your encrypted vault in -the cloud. You get 1 GB of storage for free — more than enough for a password -manager. +Mocha Suite uses [Supabase](https://supabase.com) for authentication and storage. +You get 1 GB of storage for free. -1. **Sign up** at [app.supabase.com](https://app.supabase.com) (email, GitHub, - or Google). -2. **Create a new project** — pick any name and region close to you. Set a - database password (you won't need it for Mocha, just pick something random). -3. Wait a minute for the project to finish initializing. +1. Sign up at [app.supabase.com](https://app.supabase.com). +2. Create a new project — pick any name and region. +3. Wait for the project to finish initializing. -### 3. Create the storage bucket and get your API keys +### 3. Create a storage bucket -Mocha stores the vault file in a Supabase Storage bucket called `vaults`. +Mocha Suite stores data in a Supabase Storage bucket called `vaults`. -1. In your Supabase dashboard, go to **Storage** in the left sidebar. -2. Click **New bucket**. -3. Name it exactly **`vaults`**. -4. Toggle **Public bucket** OFF (the vault should stay private). -5. Click **Create bucket**. -6. Go to **Project Settings** (gear icon, bottom left) → **API**. -7. Copy the **Project URL** — it looks like - `https://xxxxxxxx.supabase.co`. -8. Copy the **anon / publishable key** — it's a long string starting with - `eyJ...`. +1. In your Supabase dashboard, go to **Storage**. +2. Click **New bucket**, name it **`vaults`**, toggle **Public bucket** OFF. +3. Click **Create bucket**. +4. Go to **Project Settings** → **API**. +5. Copy the **Project URL** and the **anon / publishable key**. -### 4. Clone the repo and run +### 4. Clone and run ```bash -git clone https://github.com/hunixcode/hotmocha.git -cd hotmocha +git clone https://github.com/hunixcode/mocha.git +cd mocha npm install npm run tauri dev ``` -The first run compiles all Rust dependencies and takes a few minutes. Subsequent -runs are fast. +The first run compiles all Rust dependencies and takes a few minutes. ### 5. Enter your Supabase credentials -When the app opens, you'll see a **Setup required** page with a form. Paste the -**Project URL** and **anon key** you copied from your Supabase dashboard, then -click **Save & restart**. +When the app opens, paste the **Project URL** and **anon key**, then click +**Save & restart**. -> Credentials are stored locally on your machine (localStorage). They are never -> sent anywhere other than your own Supabase project. - -Alternatively, you can create a `.env` file at the project root (takes priority -over the in-app form): +Alternatively, create a `.env` file at the project root: ```bash VITE_SUPABASE_URL=https://xxxxxxxx.supabase.co VITE_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` -> The key can also be called `VITE_SUPABASE_ANON_KEY` — both work. - -### 6. Create your account and vault +### 6. Create your account -After the app restarts, click **Sign up** to create your account, then **Create -vault** to set your master password. Your vault is now encrypted and synced to -your Supabase storage. +After the app restarts, sign up with your email and password. You'll land on the +Dashboard where you can access all apps. -### Build a release binary / installer +### Build a release binary ```bash npm run tauri build ``` -Outputs land in `src-tauri/target/release/bundle/` (`.msi`/`.exe` on Windows, -`.deb`/`.rpm`/`.AppImage` on Linux, `.app`/`.dmg` on macOS). +Outputs land in `src-tauri/target/release/bundle/`. ## How it works ### Authentication flow -1. On launch, Mocha checks for an active Supabase session. -2. If no session, you see the **Sign in / Sign up** screen. Authentication uses - Supabase Auth (email + password). -3. After signing in, Mocha checks if a vault exists in Supabase Storage. +1. On launch, Mocha Suite checks for an active Supabase session. +2. If no session, you see the **Sign in / Sign up** screen. +3. After signing in, the **Dashboard** opens with access to all apps. +4. The **Password Manager** additionally requires a master password to unlock + the vault. -### Vault encryption +### Vault encryption (Password Manager) -The vault is an encrypted JSON blob stored in Supabase Storage under your user -ID at `vaults//vault.mocha`. The file holds three base64 fields: a -random 16-byte salt, a random 12-byte nonce, and the ciphertext. +The vault is an encrypted JSON blob stored in Supabase Storage at +`vaults//vault.mocha`. -1. When you create or unlock the vault, a 32-byte key is derived from your master - password and the salt using **Argon2id**. -2. The entry list (JSON) is encrypted with **AES-256-GCM** using that key and a - fresh random nonce on every save. GCM is authenticated encryption, so any - tampering — or a wrong master password — is detected and rejected. +1. A 32-byte key is derived from your master password and a random salt using + **Argon2id**. +2. Entries are encrypted with **AES-256-GCM** with a fresh random nonce on every + save. 3. The key and decrypted entries are held in memory only while the vault is - unlocked ; locking the vault drops them. - -Passwords are never written to disk in plain text. The encrypted blob is uploaded -to Supabase Storage after every mutation. The only exception is a Bitwarden -export, which is unencrypted by design — treat exported files carefully and -delete them after use. + unlocked. -There is no master-password recovery : if you forget it, the vault cannot be +There is no master-password recovery — if you forget it, the vault cannot be decrypted. -### Code layout +### Authenticator + +TOTP secrets are stored as JSON in Supabase Storage. Codes are generated +client-side using the Web Crypto API (HMAC-SHA1). + +### Subscriptions + +Subscription data is stored as JSON in Supabase Storage. The app computes +monthly/yearly totals and upcoming renewals from the stored data. + +## Code layout ``` -hotmocha/ -├── index.html Entry HTML for the webview -├── package.json Node dependencies and scripts -├── tsconfig.json TypeScript configuration -├── vite.config.ts Vite dev server and build config +mocha/ +├── index.html Entry HTML +├── package.json Dependencies and scripts ├── src/ Frontend (React + TypeScript) -│ ├── main.tsx React bootstrap -│ ├── App.tsx All screens : auth, create/unlock, vault, dialogs -│ ├── App.css Theme and layout -│ ├── types.ts Shared Entry type -│ └── supabase.ts Supabase client (reads env vars) +│ ├── main.tsx Bootstrap with providers +│ ├── App.tsx Router and gate screen +│ ├── App.css All styles +│ ├── types.ts Shared types +│ ├── supabase.ts Supabase client +│ ├── contexts/ React contexts (Auth, Theme, I18n) +│ ├── components/ Layout, Sidebar, Topbar, Gate, Icons +│ ├── pages/ Dashboard, PasswordManager, Authenticator, +│ │ Subscriptions, Settings +│ └── lib/ Storage helpers, TOTP, translations └── src-tauri/ Backend (Rust) - ├── Cargo.toml Rust crate config and dependencies - ├── tauri.conf.json Window, bundle and build configuration - ├── capabilities/ Tauri permission scopes - ├── icons/ Application icons + ├── Cargo.toml + ├── tauri.conf.json + ├── capabilities/ + ├── icons/ └── src/ - ├── lib.rs Crypto, vault persistence and all Tauri commands - └── main.rs Binary entry point + ├── lib.rs Crypto and Tauri commands + └── main.rs Entry point ``` -The frontend handles the UI and Supabase communication (auth + storage). It never -touches the vault file or performs cryptography directly. Instead, it calls Tauri -commands (`create_vault`, `unlock_vault`, `lock_vault`, `get_vault_blob`, -`add_entry`, `update_entry`, `delete_entry`, `export_bitwarden`, -`import_bitwarden`) over Tauri's IPC bridge for all crypto and vault operations. - -The data flow is : **User action → React UI → Tauri IPC (Rust) → re-encrypt → -return encrypted blob → React uploads to Supabase Storage**. - ## Usage -1. **Sign in / Sign up** — create an account with your email and password. This - is your Supabase account, separate from the vault master password. -2. **First launch** — choose a master password (minimum 8 characters) to create - the vault. -3. **Unlock** — enter the master password on later launches. **Lock** (top right) - closes the session without quitting the app. -4. **Entries** — create with **New entry** ; each list entry also has inline actions - to edit, move to a folder or delete. **Generate** in the editor fills in a random - password. -5. **Folders** — type a folder name in the entry editor (existing folders are - suggested), or use the folder action on a list entry. The sidebar shows all - folders with entry counts ; click one to filter. A folder disappears automatically - when its last entry leaves it. -6. **Search** — filters the visible list by name, username or URL. -7. **Sign out** — ends your Supabase session. You'll need to sign in again next - time. - -## Bitwarden compatibility - -**Export** saves an unencrypted Bitwarden `.json` including your folders. Import it -in Bitwarden under *Tools → Import data*, format *Bitwarden (json)*. - -**Import** accepts an unencrypted Bitwarden `.json` export (in Bitwarden : *Tools → -Export vault*, format *.json* — encrypted exports are rejected). Only login items -(`"type": 1`) are imported ; cards, identities and secure notes are skipped. -Bitwarden folders become Mocha folders, and imported entries are merged into the -existing vault. The expected file shape : - -```json -{ - "encrypted": false, - "folders": [{ "id": "f1", "name": "Work" }], - "items": [ - { - "type": 1, - "name": "Example site", - "folderId": "f1", - "notes": "Optional note", - "login": { - "username": "you@example.com", - "password": "secret", - "uris": [{ "uri": "https://example.com" }] - } - } - ] -} -``` - ---- +1. **Dashboard** — landing page with cards for each app. +2. **Password Manager** — unlock with your master password, then manage entries. + Lock returns to the unlock screen. +3. **Authenticator** — add accounts by pasting the base32 secret from your 2FA + setup page. Codes refresh every 30 seconds. +4. **Subscriptions** — search from 66 popular presets or add manually. Summary + cards show monthly/yearly spend and upcoming renewals. +5. **Settings** — switch between 6 themes and 6 languages. diff --git a/package-lock.json b/package-lock.json index 75000df..f07dafe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", @@ -1643,6 +1644,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1952,6 +1965,42 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2016,6 +2065,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 439b7e9..8ca0ab7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", diff --git a/src/App.css b/src/App.css index 0a273ad..8350e8d 100644 --- a/src/App.css +++ b/src/App.css @@ -22,6 +22,87 @@ * { box-sizing: border-box; margin: 0; padding: 0; } +/* ---------- themes ---------- */ +[data-theme="latte"] { + --bg: #f5f0eb; + --surface: #fffaf5; + --surface-alt: #f0ebe5; + --border: #e0d8d0; + --border-strong: #c8bfb5; + --ink: #2a2420; + --ink-soft: #6b6055; + --ink-faint: #b0a898; + --accent: #c8956c; + --accent-hover: #b07a50; + --accent-soft: #f0e8e0; + --danger: #d94a3a; + --danger-soft: #fce8e5; +} + +[data-theme="espresso"] { + --bg: #0f0d0b; + --surface: #181512; + --surface-alt: #14110e; + --border: #2a241e; + --border-strong: #3a322a; + --ink: #e8e0d8; + --ink-soft: #a09080; + --ink-faint: #6a6055; + --accent: #d4a060; + --accent-hover: #b8884a; + --accent-soft: #221c15; + --danger: #e06050; + --danger-soft: #2e1c18; +} + +[data-theme="matcha"] { + --bg: #131a15; + --surface: #1a241e; + --surface-alt: #161f1a; + --border: #2a3a30; + --border-strong: #3a4a40; + --ink: #e5f0ea; + --ink-soft: #a5b5aa; + --ink-faint: #6a7a6e; + --accent: #7ab86a; + --accent-hover: #5a9a4a; + --accent-soft: #1a2a1e; + --danger: #d96a5a; + --danger-soft: #2e201c; +} + +[data-theme="midnight"] { + --bg: #12161a; + --surface: #1a1f24; + --surface-alt: #161a1f; + --border: #2a323a; + --border-strong: #3a424a; + --ink: #e5eaf0; + --ink-soft: #a5aab5; + --ink-faint: #6a6e7a; + --accent: #6a9fd4; + --accent-hover: #4a7fb4; + --accent-soft: #1a2230; + --danger: #d96a5a; + --danger-soft: #2e1c18; +} + +[data-theme="caramel"] { + --bg: #f8f0e5; + --surface: #fffaf2; + --surface-alt: #f5ede0; + --border: #e8dcc8; + --border-strong: #d8c8b0; + --ink: #2a2218; + --ink-soft: #6a5a48; + --ink-faint: #b0a090; + --accent: #d4a040; + --accent-hover: #b88830; + --accent-soft: #f8f0e0; + --danger: #d94a3a; + --danger-soft: #fce8e5; +} + /* ---------- animations ---------- */ @keyframes fade-in { from { opacity: 0; } @@ -539,3 +620,566 @@ label { background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { background-color: var(--ink-faint); } + +/* ---------- suite layout ---------- */ +.suite-layout { + display: flex; + height: 100%; +} + +.sidebar-nav { + width: 200px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface); + border-right: 1px solid var(--border); + padding: 12px; + gap: 2px; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 15px; + padding: 10px 12px; + margin-bottom: 12px; + color: var(--accent); + border-bottom: 1px solid var(--border); +} + +.sidebar-links { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radius); + color: var(--ink-soft); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background 0.12s, color 0.12s; + background: transparent; + border: none; + width: 100%; + text-align: left; + cursor: pointer; +} +.nav-link:hover { background: var(--accent-soft); color: var(--ink); } +.nav-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + +.sidebar-footer { + border-top: 1px solid var(--border); + padding-top: 6px; + margin-top: 6px; +} + +.suite-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar-title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.2px; +} + +.suite-content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +/* ---------- dashboard ---------- */ +.dashboard { + max-width: 760px; + margin: 0 auto; + padding: 32px 24px; + animation: fade-in 0.2s ease-out; +} +.dashboard h1 { + font-size: 22px; + font-weight: 600; + margin-bottom: 6px; +} +.dashboard > p { + color: var(--ink-soft); + margin-bottom: 28px; + font-size: 14px; +} +.app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 16px; +} +.app-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + cursor: pointer; + text-decoration: none; + color: var(--ink); + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + animation: rise-in 0.25s ease-out; +} +.app-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-md); +} +.app-card svg { color: var(--accent); } +.app-card h2 { font-size: 15px; font-weight: 600; } +.app-card p { font-size: 12px; color: var(--ink-soft); margin: 0; line-height: 1.4; } + +/* ---------- placeholder page (stubs) ---------- */ +.placeholder-page { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 0.2s ease-out; +} +.placeholder-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--ink-faint); + text-align: center; +} +.placeholder-content .logo { color: var(--ink-faint); } +.placeholder-content h2 { font-size: 18px; font-weight: 600; color: var(--ink-soft); } +.placeholder-content p { font-size: 13px; } + +/* ---------- vault within suite ---------- */ +.vault { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + animation: fade-in 0.2s ease-out; +} + +.vault-gate { + flex: 1; + display: grid; + place-items: center; + animation: fade-in 0.2s ease-out; +} + +.vault-gate-card { + width: 360px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 36px 32px; + display: flex; + flex-direction: column; + gap: 12px; + text-align: center; + box-shadow: var(--shadow-md); + animation: rise-in 0.3s ease-out; +} +.vault-gate-card h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.2px; } +.vault-gate-card .btn { margin-top: 4px; padding: 9px 14px; } + +.vault-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; +} +.vault-actions .count { + margin-right: auto; +} + +/* ---------- authenticator ---------- */ +.auth-page { + flex: 1; + display: flex; + flex-direction: column; + padding: 24px; + max-width: 600px; + animation: fade-in 0.2s ease-out; +} + +.auth-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.auth-header h2 { font-size: 18px; font-weight: 600; } + +.auth-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + transition: border-color 0.15s; +} +.auth-item.expiring { border-color: var(--danger); } + +.auth-info { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-issuer { + font-size: 14px; + font-weight: 600; +} +.auth-code { + font-family: "Cascadia Code", "Consolas", monospace; + font-size: 22px; + font-weight: 700; + letter-spacing: 2px; + color: var(--accent); +} + +.auth-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.auth-timer-bar { + margin-top: 16px; + padding: 0 4px; +} +.auth-timer-track { + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} +.auth-timer-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s linear; +} + +/* ---------- subscriptions ---------- */ +.subs-page { + flex: 1; + display: flex; + flex-direction: column; + padding: 24px; + max-width: 720px; + animation: fade-in 0.2s ease-out; +} + +.subs-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.subs-header h2 { font-size: 18px; font-weight: 600; } + +.subs-summary { + display: flex; + gap: 12px; + margin-bottom: 24px; +} +.summary-card { + flex: 1; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 6px; +} +.summary-card.accent { border-color: var(--accent); } +.summary-label { font-size: 12px; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; } +.summary-value { font-size: 22px; font-weight: 700; } + +.subs-upcoming { + margin-bottom: 24px; +} +.subs-upcoming h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--ink-soft); } +.subs-upcoming-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.subs-upcoming-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; +} +.subs-upcoming-name { font-weight: 600; flex: 1; } +.subs-upcoming-date { color: var(--ink-soft); } +.subs-upcoming-cost { font-weight: 600; font-family: "Cascadia Code", "Consolas", monospace; } + +.subs-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.subs-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.12s; +} +.subs-item.due-soon { border-color: var(--danger); } + +.subs-item-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.subs-item-name { font-weight: 600; font-size: 14px; } +.subs-item-date { font-size: 12px; color: var(--ink-soft); } + +.subs-cat { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + display: inline-block; + width: fit-content; +} +.cat-streaming { background: #1a2e2a; color: #5fc9b8; } +.cat-cloud { background: #1e2a3a; color: #6b9fd4; } +.cat-productivity { background: #2a241e; color: #d4a86b; } +.cat-development { background: #2a1e2e; color: #b87ad4; } +.cat-music { background: #2e1e1e; color: #d46b6b; } +.cat-news { background: #2e2a1e; color: #c9b85f; } +.cat-other { background: var(--accent-soft); color: var(--accent); } + +.subs-item-cost { + text-align: right; + flex-shrink: 0; +} +.subs-cost-value { font-weight: 700; font-size: 16px; font-family: "Cascadia Code", "Consolas", monospace; } +.subs-cost-cycle { font-size: 12px; color: var(--ink-soft); } + +.subs-item-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +select { + font: inherit; + color: var(--ink); + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + outline: none; + cursor: pointer; + transition: border-color 0.12s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23b5a99a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 32px; +} +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(200, 149, 108, 0.15); +} + +/* ---------- settings ---------- */ +.settings-page { + max-width: 640px; + padding: 32px 24px; + animation: fade-in 0.2s ease-out; +} +.settings-page > h2 { + font-size: 20px; + font-weight: 600; + margin-bottom: 24px; +} + +.settings-section { + margin-bottom: 32px; +} +.settings-section > h3 { + font-size: 14px; + font-weight: 600; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} +.settings-field > label { + font-size: 13px; + font-weight: 600; + color: var(--ink); + flex-direction: row; +} +.settings-field select { max-width: 260px; } + +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; +} + +.theme-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px 10px 10px; + background: var(--surface); + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 0.15s; + font-size: 12px; + font-weight: 500; + color: var(--ink-soft); +} +.theme-card:hover { border-color: var(--ink-faint); } +.theme-card.active { border-color: var(--accent); color: var(--accent); font-weight: 600; } + +.theme-preview { + display: flex; + gap: 4px; + align-items: center; + justify-content: center; + height: 36px; +} +.tp-bg { + width: 22px; + height: 22px; + border-radius: 4px; +} +.tp-surface { + width: 22px; + height: 22px; + border-radius: 4px; +} +.tp-accent { + width: 10px; + height: 22px; + border-radius: 3px; +} + +.theme-card[data-theme-preview="mocha"] .tp-bg { background: #1a1512; } +.theme-card[data-theme-preview="mocha"] .tp-surface { background: #241e19; } +.theme-card[data-theme-preview="mocha"] .tp-accent { background: #c8956c; } + +.theme-card[data-theme-preview="latte"] .tp-bg { background: #f5f0eb; } +.theme-card[data-theme-preview="latte"] .tp-surface { background: #fffaf5; } +.theme-card[data-theme-preview="latte"] .tp-accent { background: #c8956c; } + +.theme-card[data-theme-preview="espresso"] .tp-bg { background: #0f0d0b; } +.theme-card[data-theme-preview="espresso"] .tp-surface { background: #181512; } +.theme-card[data-theme-preview="espresso"] .tp-accent { background: #d4a060; } + +.theme-card[data-theme-preview="matcha"] .tp-bg { background: #131a15; } +.theme-card[data-theme-preview="matcha"] .tp-surface { background: #1a241e; } +.theme-card[data-theme-preview="matcha"] .tp-accent { background: #7ab86a; } + +.theme-card[data-theme-preview="midnight"] .tp-bg { background: #12161a; } +.theme-card[data-theme-preview="midnight"] .tp-surface { background: #1a1f24; } +.theme-card[data-theme-preview="midnight"] .tp-accent { background: #6a9fd4; } + +.theme-card[data-theme-preview="caramel"] .tp-bg { background: #f8f0e5; } +.theme-card[data-theme-preview="caramel"] .tp-surface { background: #fffaf2; } +.theme-card[data-theme-preview="caramel"] .tp-accent { background: #d4a040; } + +/* ---------- subscription suggestions ---------- */ +.subs-modal .modal { max-width: 520px; } + +.suggestion-panel { + border-bottom: 1px solid var(--border); + padding-bottom: 12px; + margin-bottom: 4px; +} + +.suggestion-list { + max-height: 200px; + overflow-y: auto; + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.suggestion-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; + transition: background 0.1s; + width: 100%; +} +.suggestion-item:hover { background: var(--accent-soft); } + +.suggestion-name { flex: 1; font-weight: 600; font-size: 13px; } + +.suggestion-cycle { + font-size: 11px; + color: var(--ink-faint); + text-transform: uppercase; + letter-spacing: 0.3px; +} diff --git a/src/App.tsx b/src/App.tsx index 8f7616f..00de403 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,1126 +1,31 @@ -import { FormEvent, useEffect, useMemo, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { open, save } from "@tauri-apps/plugin-dialog"; -import { User } from "@supabase/supabase-js"; -import { supabase, supabaseReady, saveSupabaseConfig } from "./supabase"; -import { Entry } from "./types"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { useAuth } from "./contexts/AuthContext"; +import Gate from "./components/Gate"; +import Layout from "./components/Layout"; +import Dashboard from "./pages/Dashboard"; +import PasswordManager from "./pages/PasswordManager"; +import Authenticator from "./pages/Authenticator"; +import Subscriptions from "./pages/Subscriptions"; +import Settings from "./pages/Settings"; import "./App.css"; -type Screen = "loading" | "auth" | "create" | "unlock" | "vault"; -type AuthMode = "signin" | "signup"; - -const emptyForm: Entry = { - id: "", - name: "", - username: "", - password: "", - url: "", - notes: "", - folder: "", -}; - -function ActionIcon({ path }: { path: string }) { - return ( - - ); -} - -const ICON_EDIT = "M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"; -const ICON_FOLDER = - "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z"; -const ICON_TRASH = - "M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"; - -function Logo({ size = 40 }: { size?: number }) { - return ( - - ); -} - -const VAULT_PATH = "vault.mocha"; - -type ResizeDir = "North" | "South" | "East" | "West" | "NorthEast" | "NorthWest" | "SouthEast" | "SouthWest"; - -const CURSOR_MAP: Record = { - North: "n-resize", - South: "s-resize", - East: "e-resize", - West: "w-resize", - NorthEast: "ne-resize", - NorthWest: "nw-resize", - SouthEast: "se-resize", - SouthWest: "sw-resize", -}; - -function ResizeHandle({ dir, className }: { dir: ResizeDir; className?: string }) { - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - getCurrentWindow().startResizeDragging(dir); - }} - /> - ); -} - -function ResizeHandles() { - return ( - <> - - - - - - - - - - ); -} - -async function uploadVault(userId: string, blob: string) { - const { error } = await supabase.storage - .from("vaults") - .upload(`${userId}/${VAULT_PATH}`, blob, { - contentType: "application/json", - upsert: true, - }); - if (error) throw error; -} - -async function downloadVault( - userId: string -): Promise { - const { data, error } = await supabase.storage - .from("vaults") - .download(`${userId}/${VAULT_PATH}`); - if (error) { - if (error.message.includes("not found") || error.status === 404) return null; - throw error; - } - return await data.text(); -} - export default function App() { - const [screen, setScreen] = useState("loading"); - const [user, setUser] = useState(null); - const [authMode, setAuthMode] = useState("signin"); - const [email, setEmail] = useState(""); - const [authPassword, setAuthPassword] = useState(""); - const [master, setMaster] = useState(""); - const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(""); - const [entries, setEntries] = useState([]); - const [search, setSearch] = useState(""); - const [selectedId, setSelectedId] = useState(null); - const [editing, setEditing] = useState(false); - const [form, setForm] = useState(emptyForm); - const [showPw, setShowPw] = useState(false); - const [toast, setToast] = useState(""); - const [showImportInfo, setShowImportInfo] = useState(false); - const [activeFolder, setActiveFolder] = useState(null); - const [moveEntry, setMoveEntry] = useState(null); - const [moveFolder, setMoveFolder] = useState(""); - const [setupUrl, setSetupUrl] = useState(""); - const [setupKey, setSetupKey] = useState(""); - const [setupError, setSetupError] = useState(""); - const [setupSaving, setSetupSaving] = useState(false); - - function startDrag(e: React.MouseEvent) { - if ((e.target as HTMLElement).closest("button, input, textarea, a")) return; - getCurrentWindow().startDragging(); - } - - function handleSetup(e: FormEvent) { - e.preventDefault(); - setSetupError(""); - const url = setupUrl.trim(); - const key = setupKey.trim(); - if (!url) return setSetupError("Project URL is required."); - if (!key) return setSetupError("API key is required."); - try { - new URL(url); - } catch { - return setSetupError("Invalid URL. Make sure it includes https://"); - } - setSetupSaving(true); - saveSupabaseConfig(url, key); - window.location.reload(); - } - - useEffect(() => { - if (!supabaseReady) return; - supabase.auth.getSession().then(({ data: { session } }) => { - if (session?.user) { - setUser(session.user); - checkVault(session.user.id); - } else { - setScreen("auth"); - } - }); - }, []); - - async function checkVault(userId: string) { - try { - const vaultData = await downloadVault(userId); - if (vaultData) { - setScreen("unlock"); - } else { - setScreen("create"); - } - } catch { - setScreen("create"); - } - } - - const folders = useMemo( - () => - Array.from(new Set(entries.map((e) => e.folder).filter(Boolean))).sort( - (a, b) => a.localeCompare(b) - ), - [entries] - ); - const unfiledCount = useMemo( - () => entries.filter((e) => !e.folder).length, - [entries] - ); - - useEffect(() => { - if (activeFolder === null) return; - const gone = - activeFolder === "" ? unfiledCount === 0 : !folders.includes(activeFolder); - if (gone) setActiveFolder(null); - }, [activeFolder, folders, unfiledCount]); - - const filtered = useMemo(() => { - const inFolder = - activeFolder === null - ? entries - : entries.filter((e) => - activeFolder === "" ? !e.folder : e.folder === activeFolder - ); - const q = search.trim().toLowerCase(); - const list = q - ? inFolder.filter( - (e) => - e.name.toLowerCase().includes(q) || - e.username.toLowerCase().includes(q) || - e.url.toLowerCase().includes(q) - ) - : inFolder; - return [...list].sort((a, b) => a.name.localeCompare(b.name)); - }, [entries, search, activeFolder]); - - const selected = entries.find((e) => e.id === selectedId) ?? null; - - function notify(msg: string) { - setToast(msg); - window.setTimeout(() => setToast(""), 1800); - } + const { user, loading, supabaseReady } = useAuth(); - function enterVault(list: Entry[]) { - setEntries(list); - setScreen("vault"); - setMaster(""); - setConfirm(""); - setError(""); - } - - async function handleAuth(e: FormEvent) { - e.preventDefault(); - setError(""); - if (!email.trim() || !authPassword) { - return setError("Email and password are required."); - } - try { - const { data, error: authError } = - authMode === "signin" - ? await supabase.auth.signInWithPassword({ - email, - password: authPassword, - }) - : await supabase.auth.signUp({ - email, - password: authPassword, - }); - if (authError) throw authError; - if (data.user) { - setUser(data.user); - await checkVault(data.user.id); - } - } catch (err) { - setError(String(err)); - } - } - - async function handleSignOut() { - await supabase.auth.signOut(); - setUser(null); - setEntries([]); - setSelectedId(null); - setEditing(false); - setEmail(""); - setAuthPassword(""); - setScreen("auth"); - } - - async function handleCreate(e: FormEvent) { - e.preventDefault(); - setError(""); - if (master.length < 8) - return setError("Master password must be at least 8 characters."); - if (master !== confirm) return setError("Passwords do not match."); - if (!user) return setError("Not authenticated."); - try { - const [list, blob] = await invoke<[Entry[], string]>("create_vault", { - master, - }); - await uploadVault(user.id, blob); - enterVault(list); - } catch (err) { - setError(String(err)); - } - } - - async function handleUnlock(e: FormEvent) { - e.preventDefault(); - setError(""); - if (!user) return setError("Not authenticated."); - try { - const vaultData = await downloadVault(user.id); - if (!vaultData) return setError("No vault found on server."); - const list = await invoke("unlock_vault", { - master, - vaultData, - }); - enterVault(list); - } catch (err) { - setError(String(err)); - } - } - - async function handleLock() { - await invoke("lock_vault"); - setEntries([]); - setSelectedId(null); - setEditing(false); - setScreen("unlock"); - } - - function startNew() { - setForm({ ...emptyForm, folder: activeFolder ?? "" }); - setSelectedId(null); - setEditing(true); - setShowPw(false); - } - - function startEdit(entry: Entry) { - setForm(entry); - setEditing(true); - setShowPw(false); - } - - async function handleSave(e: FormEvent) { - e.preventDefault(); - if (!form.name.trim()) return notify("Name is required"); - if (!user) return notify("Not authenticated"); - const entry = { ...form, folder: form.folder.trim() }; - try { - if (entry.id) { - const blob = await invoke("update_entry", { entry }); - setEntries((prev) => prev.map((x) => (x.id === entry.id ? entry : x))); - setSelectedId(entry.id); - await uploadVault(user.id, blob); - } else { - const [created, blob] = await invoke<[Entry, string]>("add_entry", { - entry, - }); - setEntries((prev) => [...prev, created]); - setSelectedId(created.id); - await uploadVault(user.id, blob); - } - setEditing(false); - notify("Saved"); - } catch (err) { - notify(String(err)); - } - } - - async function handleDelete(entry: Entry) { - if (!window.confirm(`Delete "${entry.name}"?`)) return; - if (!user) return notify("Not authenticated"); - try { - const blob = await invoke("delete_entry", { id: entry.id }); - setEntries((prev) => prev.filter((x) => x.id !== entry.id)); - setSelectedId(null); - setEditing(false); - await uploadVault(user.id, blob); - notify("Deleted"); - } catch (err) { - notify(String(err)); - } - } - - async function handleMove() { - if (!moveEntry || !user) return; - const entry = { ...moveEntry, folder: moveFolder.trim() }; - try { - const blob = await invoke("update_entry", { entry }); - setEntries((prev) => prev.map((x) => (x.id === entry.id ? entry : x))); - await uploadVault(user.id, blob); - setMoveEntry(null); - notify(entry.folder ? `Moved to ${entry.folder}` : "Removed from folder"); - } catch (err) { - notify(String(err)); - } - } - - async function copy(text: string, label: string) { - await navigator.clipboard.writeText(text); - notify(`${label} copied`); - } - - async function handleExport() { - try { - const path = await save({ - defaultPath: "mocha-bitwarden.json", - filters: [{ name: "JSON", extensions: ["json"] }], - }); - if (!path) return; - await invoke("export_bitwarden", { path }); - notify("Vault exported"); - } catch (err) { - notify(String(err)); - } - } - - async function handleImport() { - setShowImportInfo(false); - if (!user) return notify("Not authenticated"); - try { - const path = await open({ - multiple: false, - filters: [{ name: "JSON", extensions: ["json"] }], - }); - if (!path) return; - const [list, blob] = await invoke<[Entry[], string]>("import_bitwarden", { - path, - }); - setEntries(list); - await uploadVault(user.id, blob); - notify("Vault imported"); - } catch (err) { - notify(String(err)); - } - } - - function generatePassword() { - const chars = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; - const bytes = new Uint32Array(20); - crypto.getRandomValues(bytes); - const pw = Array.from(bytes, (b) => chars[b % chars.length]).join(""); - setForm((f) => ({ ...f, password: pw })); - setShowPw(true); - } - - if (!supabaseReady) { - return ( -
- -
-
- - - -
-
-
-
- -
-

Setup required

-

- Mocha needs a Supabase project to store your vault. -

-
-
- 1 -
- Create a Supabase project -

Go to app.supabase.com and create a free project.

-
-
-
- 2 -
- Create a storage bucket -

In Storage, create a bucket named vaults (private).

-
-
-
- 3 -
- Get your API keys -

In Project Settings → API, copy the Project URL and anon key.

-
-
-
- 4 -
- Enter your credentials below -

Paste the values you just copied.

-
-
-
-
- - - {setupError &&
{setupError}
} -
- -
-
-

- Credentials are stored locally on your machine. You can also set them - in a .env file at the project root. -

-
-
- ); - } - - if (screen === "loading") { - return ( -
- -
-
- - - -
-
-
- -
-
- ); - } - - if (screen === "auth") { - const signing = authMode === "signin"; - return ( -
- -
-
- - - -
-
-
-
- -
-

Mocha

-

- {signing - ? "Sign in to access your vault." - : "Create an account to get started."} -

- setEmail(e.target.value)} - autoFocus - /> - setAuthPassword(e.target.value)} - /> - {error &&
{error}
} - - -
-
- ); - } - - if (screen === "create" || screen === "unlock") { - const creating = screen === "create"; - return ( -
- -
-
- - - -
-
-
-
- -
-

Mocha

-

- {creating - ? "Set a master password to encrypt your vault." - : "Enter your master password to unlock your vault."} -

- setMaster(e.target.value)} - autoFocus - /> - {creating && ( - setConfirm(e.target.value)} - /> - )} - {error &&
{error}
} - - -
-
- ); + if (!supabaseReady || loading || !user) { + return ; } return ( -
- -
-
- - - {" "} - Mocha - - {entries.length} {entries.length === 1 ? "entry" : "entries"} - -
-
- - - - -
e.stopPropagation()}> - - - -
-
-
- -
- - -
- {editing ? ( -
-

{form.id ? "Edit entry" : "New entry"}

- - - - - -