From 0b2610e45bbe202d3e7aa5668167a609ab12ea67 Mon Sep 17 00:00:00 2001 From: ManliestBen Date: Wed, 18 Feb 2026 08:17:17 -0600 Subject: [PATCH 1/5] configure google oauth refresh token with necessary scopes for project features --- dashboard/package-lock.json | 139 +++++++++++++++++++++++++ dashboard/package.json | 4 +- dashboard/scripts/oauth-get-tokens.js | 144 ++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 dashboard/scripts/oauth-get-tokens.js diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 2d61d35..6c695c2 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,6 +13,7 @@ "express": "^4.18.2", "googleapis": "^128.0.0", "http-proxy-middleware": "^2.0.6", + "open": "^10.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1920,6 +1921,20 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2301,6 +2316,43 @@ "node": ">=4.0.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3123,6 +3175,20 @@ "node": ">=8" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3151,6 +3217,23 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3187,6 +3270,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3681,6 +3778,23 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -4040,6 +4154,17 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -5493,6 +5618,20 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 7da1ad1..fc47324 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -11,10 +11,12 @@ "preview": "vite preview", "serve": "nodemon server.js", "test": "vitest run", - "test:watch": "vitest" +"test:watch": "vitest", + "oauth-get-tokens": "node scripts/oauth-get-tokens.js" }, "dependencies": { "better-sqlite3": "^12.6.2", + "open": "^10.0.0", "dotenv": "^16.3.1", "express": "^4.18.2", "googleapis": "^128.0.0", diff --git a/dashboard/scripts/oauth-get-tokens.js b/dashboard/scripts/oauth-get-tokens.js new file mode 100644 index 0000000..021003e --- /dev/null +++ b/dashboard/scripts/oauth-get-tokens.js @@ -0,0 +1,144 @@ +/** + * One-time OAuth flow to get a refresh token for the Canopy Gmail user + * (Photos Library, Photos Ambient, Picker, Gmail, Calendar). + * + * Usage (interactive – script starts a server and opens the browser): + * 1. Put your OAuth client JSON at dashboard/oauth-credentials.json + * 2. In Google Cloud Console, set authorized redirect URI to exactly: http://localhost:3000/oauth2callback + * 3. From dashboard/: npm install && npm run oauth-get-tokens + * 4. Sign in, approve scopes; you’ll be redirected to localhost:3000 and the script will print the token. + * + * Usage (manual – you already have the code from a redirect to e.g. http://localhost/?code=...): + * npm run oauth-get-tokens -- "PASTE_THE_CODE_HERE" + * If Google sent you to http://localhost/ (no port), use: + * npm run oauth-get-tokens -- "PASTE_THE_CODE_HERE" --redirect-uri "http://localhost/" + */ + +import { createServer } from 'http'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { google } from 'googleapis'; +import open from 'open'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const REDIRECT_PORT = 3000; +const REDIRECT_PATH = '/oauth2callback'; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + +const SCOPES = [ + 'https://www.googleapis.com/auth/photoslibrary.readonly', + 'https://www.googleapis.com/auth/photosambient.mediaitems', + 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/calendar', +]; + +// Resolve credentials path: from dashboard/scripts/ -> dashboard/oauth-credentials.json +const credentialsPath = path.join(__dirname, '..', 'oauth-credentials.json'); +let credentials; +try { + credentials = JSON.parse(readFileSync(credentialsPath, 'utf8')); +} catch (err) { + console.error('Failed to load oauth-credentials.json from', credentialsPath); + console.error('Download the OAuth client JSON from Google Cloud Console and save it there.'); + process.exit(1); +} + +const client = credentials.web || credentials.installed; +if (!client || !client.client_id || !client.client_secret) { + console.error('oauth-credentials.json must contain .web or .installed with client_id and client_secret.'); + process.exit(1); +} + +// Optional: run in manual code mode (code passed as first arg; optional --redirect-uri) +const args = process.argv.slice(2); +const manualCode = args.find((a) => !a.startsWith('--')); +const redirectUriArg = args.find((a) => a.startsWith('--redirect-uri=')); +const manualRedirectUri = redirectUriArg ? redirectUriArg.split('=')[1] : null; + +async function exchangeCode(code, redirectUri) { + const oauth2Client = new google.auth.OAuth2( + client.client_id, + client.client_secret, + redirectUri + ); + const { tokens } = await oauth2Client.getToken(code); + console.log('\n--- Store this in dashboard/.env (do not commit) ---\n'); + console.log('GOOGLE_OAUTH_REFRESH_TOKEN=' + (tokens.refresh_token || '')); + console.log('\n--- End ---\n'); + if (!tokens.refresh_token) { + console.warn('No refresh_token in response. You may need to revoke app access and run again with prompt=consent.'); + } +} + +if (manualCode) { + const redirectUri = manualRedirectUri || 'http://localhost/'; + exchangeCode(manualCode, redirectUri) + .then(() => process.exit(0)) + .catch((err) => { + console.error('Failed to exchange code for tokens:', err.message); + if (err.message?.includes('redirect_uri')) { + console.error('Try passing the exact redirect URI Google used, e.g.: --redirect-uri "http://localhost/"'); + } + process.exit(1); + }); +} else { + +const oauth2Client = new google.auth.OAuth2( + client.client_id, + client.client_secret, + client.redirect_uris?.[0] || REDIRECT_URI +); + +const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: SCOPES, +}); + +const server = createServer(async (req, res) => { + const reqUrl = req.url || ''; + const isCallback = reqUrl.startsWith(REDIRECT_PATH) || (reqUrl.startsWith('/') && reqUrl.includes('code=')); + if (!isCallback) { + res.writeHead(404).end('Not found'); + return; + } + const qs = new URL(reqUrl, `http://localhost:${REDIRECT_PORT}`).searchParams; + const code = qs.get('code'); + const error = qs.get('error'); + if (error) { + res.writeHead(200).end(`Authorization failed: ${error}. Check the terminal for details.`); + server.close(); + console.error('OAuth error:', error, qs.get('error_description') || ''); + process.exit(1); + } + if (!code) { + res.writeHead(200).end('No code in callback. Try again.'); + server.close(); + process.exit(1); + } + res.writeHead(200, { 'Content-Type': 'text/html' }).end( + '

Success. You can close this tab.

Check the terminal for the refresh token.

' + ); + server.close(); + try { + await exchangeCode(code, client.redirect_uris?.[0] || REDIRECT_URI); + } catch (err) { + console.error('Failed to exchange code for tokens:', err.message); + process.exit(1); + } +}); + + server.listen(REDIRECT_PORT, () => { + console.log('Opening browser for Google sign-in. Sign in as your Canopy Gmail (e.g. mackinaw.canopy@gmail.com).\n'); + console.log('If the browser did not open, visit this URL:\n'); + console.log(authUrl); + console.log(''); + open(authUrl).catch(() => { + console.log('Could not open browser automatically. Copy the URL above into your browser.'); + }); + }); +} From 31e2e6e8a087e8d8cf5d31a2afcf1a3962848abb Mon Sep 17 00:00:00 2001 From: ManliestBen Date: Wed, 18 Feb 2026 08:18:00 -0600 Subject: [PATCH 2/5] configure google oauth refresh token with necessary scopes for project features --- ...gle-apis-photos-and-notifications-setup.md | 223 ++++++++++++++++++ feature-list/feature-list.md | 3 +- 2 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 docs/google-apis-photos-and-notifications-setup.md diff --git a/docs/google-apis-photos-and-notifications-setup.md b/docs/google-apis-photos-and-notifications-setup.md new file mode 100644 index 0000000..b03a5aa --- /dev/null +++ b/docs/google-apis-photos-and-notifications-setup.md @@ -0,0 +1,223 @@ +# Google APIs setup: Photos & notifications (Gmail) + +This guide walks through configuring **Google Photos Library API** (for the photo slideshow) and **Gmail API** (for sending notifications, reminders, or announcements from the Canopy Gmail account). You can use the **same Google Cloud project** you already use for Calendar. + +**Auth model:** + +| Feature | Auth | Why | +|--------|------|-----| +| **Calendar** | Service account (`SERVICE_ACCOUNT.json`) | Currently a separate Gmail/account; you share calendars with that service account email. Stretch goal: move to **mackinaw.canopy@gmail.com** (see feature list). | +| **Photos** | OAuth 2.0 (user = Canopy Gmail) | Photos API does not support service accounts. You sign in once as the Canopy Gmail user (**mackinaw.canopy@gmail.com**); family shares albums with that account. | +| **Gmail (send)** | OAuth 2.0 (user = Canopy Gmail) | Gmail API cannot send mail via service account; you authorize the app to send as the Canopy Gmail user. | + +--- + +## Prerequisites + +- A **Google Cloud project** (the one you use for Calendar, or create a new one). +- A **Gmail account for Canopy** (**mackinaw.canopy@gmail.com**) that will own the OAuth tokens for Photos and Gmail. (Calendar stays on its current service account for now.) +- For **Photos**: Family members share Google Photos albums with this Canopy Gmail account so the app can display them in the slideshow. + +--- + +## Part 1: One-time Google Cloud setup + +### 1.1 Open your project + +1. Go to [Google Cloud Console](https://console.cloud.google.com/). +2. Select the **project** you use for Canopy Calendar (or create a new project and note its name). + +### 1.2 Enable the APIs + +1. In the left menu go to **APIs & Services** → **Library**. +2. Search for and enable: + - **Google Photos Library API** — for listing and reading albums/photos for the slideshow. + - **Google Photos Ambient API** — for ambient/slideshow devices ([reference](https://developers.google.com/photos/ambient/reference/rest)); used by `mediaItems.list` for user-configured media sources. + - **Google Photos Picker API** — for letting users pick photos from their library (e.g. in a web or app flow). + - **Gmail API** — for sending emails (reminders, announcements) from the Canopy account. + - **Google Calendar API** — for Calendar (already enabled if you use Calendar today; needed for OAuth when you consolidate to mackinaw.canopy@gmail.com). + +Enabling these up front avoids re-running the OAuth consent flow when you add features later. + +### 1.3 OAuth consent screen + +Photos and Gmail use **OAuth 2.0** (user login), so you need a consent screen. You’ll authorize the app once as the Canopy Gmail user. + +1. Go to **APIs & Services** → **OAuth consent screen**. +2. Choose **External** (for a personal Gmail like `canopy@gmail.com`) or **Internal** (only if the Canopy account is in a Google Workspace org). +3. Click **Create** (or edit if it already exists). +4. Fill in: + - **App name:** e.g. `Canopy` + - **User support email:** your email + - **Developer contact:** your email +5. Click **Save and Continue**. +6. **Scopes:** + - Click **Add or Remove Scopes**. + - Add the following so you don’t have to re-consent later when you add features: + + | Scope | Purpose | + |-------|---------| + | `https://www.googleapis.com/auth/photoslibrary.readonly` | Photos Library API: read albums and media (slideshow). | + | `https://www.googleapis.com/auth/photosambient.mediaitems` | [Photos Ambient API](https://developers.google.com/photos/ambient/reference/rest): list ambient media items for a device ([mediaItems.list](https://developers.google.com/photos/ambient/reference/rest/v1/mediaItems/list)). | + | `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` | Photos Picker API: create sessions and list media items for picker flows. | + | `https://www.googleapis.com/auth/gmail.send` | Gmail API: send email as the signed-in user (notifications). | + | `https://www.googleapis.com/auth/calendar.readonly` | Calendar API: read events (for when you consolidate Calendar to OAuth / mackinaw.canopy@gmail.com). | + | `https://www.googleapis.com/auth/calendar` | Calendar API: create/edit/delete events (same consolidation case). | + + - Remove any you truly don’t need, then Save. +7. **Test users** (if app is in “Testing”): + - Add **mackinaw.canopy@gmail.com** so that account can sign in during testing. +8. **Save and Continue** through the summary. + +### 1.4 Create OAuth 2.0 credentials + +1. Go to **APIs & Services** → **Credentials**. +2. Click **Create Credentials** → **OAuth client ID**. +3. **Application type:** + - **Desktop app** — if the app runs on your machine (e.g. dev or a Pi) and you can open a browser once to sign in. + - **Web application** — if the app has a web server and you’ll use a redirect URL (e.g. `http://localhost:3000/oauth2callback`). +4. Name it (e.g. `Canopy Photos & Gmail`). +5. If you chose **Web application**, add an **Authorized redirect URI** (e.g. `http://localhost:5173/oauth2callback` for local dev). +6. Click **Create**. +7. Download the JSON: click the download icon next to the new OAuth client. Save it somewhere safe (e.g. `dashboard/oauth-credentials.json`). + - **Do not commit this file.** The repo’s `.gitignore` already ignores `*-credentials.json`. + +You should see a **Client ID** and **Client secret** in the JSON. The app will use these plus a **refresh token** (obtained in Part 2) to get access tokens for Photos and Gmail. + +--- + +## Part 2: First-time sign-in (refresh token for Canopy Gmail) + +The app needs a **refresh token** for the Canopy Gmail user so it can call Photos and Gmail without opening a browser every time. You do this once per environment (dev machine, production server). + +### 2.1 Run a one-time auth flow + +You need a small script or your app to: + +1. Open a browser to Google’s OAuth URL with your client ID and scopes. +2. Have the **Canopy Gmail user** sign in and approve the requested scopes (Photos, Ambient, Picker, Gmail, and Calendar if you added them). +3. Receive the authorization code (via redirect or copy from the browser). +4. Exchange the code for **access_token** and **refresh_token**. +5. Store the **refresh_token** securely (env var or a file that is not committed). + +**Scopes to request** (match what you added in the consent screen): + +- `https://www.googleapis.com/auth/photoslibrary.readonly` +- `https://www.googleapis.com/auth/photosambient.mediaitems` +- `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` +- `https://www.googleapis.com/auth/gmail.send` +- `https://www.googleapis.com/auth/calendar.readonly` +- `https://www.googleapis.com/auth/calendar` + +**Ready-made script:** + +A one-time script is in the repo. Use it like this: + +1. Download your OAuth client JSON from Google Cloud Console and save it as **`dashboard/oauth-credentials.json`**. +2. In the OAuth client settings, add **`http://localhost:3000/oauth2callback`** as an authorized redirect URI (Web application type). +3. From the **dashboard** directory: + + ```bash + cd dashboard + npm install + npm run oauth-get-tokens + ``` + +4. A browser opens; sign in as **mackinaw.canopy@gmail.com** (or your Canopy Gmail) and approve the scopes. +5. The terminal prints `GOOGLE_OAUTH_REFRESH_TOKEN=...`. Copy that line into **`dashboard/.env`**. + +**Important:** The OAuth client’s redirect URI in Google Cloud Console must be **exactly** `http://localhost:3000/oauth2callback` (port 3000, path `/oauth2callback`). If it’s set to `http://localhost/` (no port), Google will redirect there and the script’s server won’t receive the code — you’ll see “localhost refused to connect” because nothing is listening on port 80. Fix the redirect URI in the console and run the script again, or use the manual option below. + +**If you already got redirected to `http://localhost/?code=...`** (and the page failed to load), you can still get a refresh token without redoing the flow. From **dashboard/** run (paste your code in place of `CODE`): + +```bash +npm run oauth-get-tokens -- "CODE" --redirect-uri "http://localhost/" +``` + +Use the exact redirect URI Google used (e.g. `http://localhost/` with or without trailing slash). The script will exchange the code for tokens and print `GOOGLE_OAUTH_REFRESH_TOKEN=...`. + +The script is **`dashboard/scripts/oauth-get-tokens.js`**. It uses the dashboard’s existing `googleapis` and the `open` package (already in `package.json`) to open the browser. + +### 2.2 Store the refresh token + +- **Development:** Put the refresh token in `.env` (do not commit): + + ```env + GOOGLE_OAUTH_REFRESH_TOKEN=your_refresh_token_here + ``` + +- **Production:** Use the same env var or your secrets manager. Never commit the refresh token. + +--- + +## Part 3: App configuration + +### 3.1 OAuth credentials file + +- Keep the downloaded OAuth client JSON as e.g. `dashboard/oauth-credentials.json`. +- Ensure it’s in `.gitignore`: + + ``` + oauth-credentials.json + .env + SERVICE_ACCOUNT.json + ``` + +### 3.2 Environment variables + +In `dashboard/.env` (or production env): + +```env +# Existing +VITE_HA_BASE_URL=... +VITE_HA_TOKEN=... + +# Photos & Gmail (OAuth) +GOOGLE_OAUTH_REFRESH_TOKEN=... # from Part 2 +# Optional: path to oauth client JSON if not default +# GOOGLE_OAUTH_CREDENTIALS_PATH=dashboard/oauth-credentials.json +``` + +Your app code will: + +1. Load the OAuth client ID and secret from `oauth-credentials.json`. +2. Use `GOOGLE_OAUTH_REFRESH_TOKEN` to get an access token when calling Photos or Gmail APIs. +3. Use that access token for Photos Library, Photos Ambient, Photos Picker, Gmail send, and (when implemented) Calendar. + +### 3.3 Photos: sharing albums with the Canopy account + +- Have family members **share** their Google Photos albums with the **Canopy Gmail address** (the one you used to get the refresh token). +- In the app, when you call the Photos Library API as that user, you’ll see: + - Albums owned by that account. + - Albums shared with that account (shared albums appear in the list). +- Use the existing APIs (e.g. `albums.list`, `mediaItems.search`) to drive the slideshow from those albums. + +### 3.4 Gmail: sending notifications + +- With the same refresh token and `gmail.send` scope, your app can call the Gmail API `messages.send` to send email as the Canopy Gmail user. +- Use this for: event reminders, “Dinner’s ready”–style announcements, or daily digest emails, so notifications come from a consistent Canopy identity. + +--- + +## Part 4: Security checklist + +- [ ] **OAuth client JSON** (`oauth-credentials.json`) is in `.gitignore` and never committed. +- [ ] **Refresh token** is only in `.env` or a secrets store, never in code or Git. +- [ ] **Service account key** (`SERVICE_ACCOUNT.json`) remains only for Calendar and is not committed. +- [ ] If the refresh token is ever exposed, revoke it in [Google Account → Security → Third-party access](https://myaccount.google.com/permissions) and run the Part 2 flow again to get a new one. + +--- + +## Summary + +| Item | Where / How | +|------|-------------| +| Google Cloud project | Same as Calendar (or new project). | +| APIs enabled | Photos Library API, Gmail API (Calendar already enabled). | +| OAuth consent screen | External (or Internal for Workspace); scopes: `photoslibrary.readonly`, `photosambient.mediaitems`, `photospicker.mediaitems.readonly`, `gmail.send`, `calendar.readonly`, `calendar`. | +| OAuth client | Create OAuth client ID (Desktop or Web), download JSON → `oauth-credentials.json`. | +| Refresh token | One-time browser sign-in as Canopy Gmail → store in `GOOGLE_OAUTH_REFRESH_TOKEN`. | +| Photos | Family shares albums with Canopy Gmail; app uses OAuth to list/read via Photos Library API. | +| Gmail | App uses same OAuth (refresh token) to send mail via Gmail API as Canopy Gmail. | + +After this, your app can use the Photos Library API for the slideshow and the Gmail API for notifications without storing the Canopy Gmail password; only the refresh token and OAuth client credentials are needed on the server. diff --git a/feature-list/feature-list.md b/feature-list/feature-list.md index 12c76f6..468954b 100644 --- a/feature-list/feature-list.md +++ b/feature-list/feature-list.md @@ -139,10 +139,11 @@ A combination of **Home Assistant**–style smart home control and **Skylight** ### Canopy service Gmail account -- [ ] **Dedicated Gmail** — A Gmail account has been created for the Canopy service. Potential uses (no code yet; ideas for the roadmap): +- [ ] **Dedicated Gmail** — A Gmail account has been created for the Canopy service (**mackinaw.canopy@gmail.com**). Potential uses (no code yet; ideas for the roadmap): - **Outbound:** Event or task reminders, family announcements (“Dinner’s ready”), daily agenda digest, or “what’s on the panel today” emails. - **Inbound:** “Email to add” flows (e.g. forward an email to create a task or quick event), or invite/guest-access links sent by email. - **Auth:** Sending from the panel via OAuth or app password for the above. Notifications could be sent through this account so they come from a consistent “Canopy” identity. +- [ ] **Stretch: Consolidate Calendar to Canopy Gmail** — Calendar currently uses a different Gmail account and `dashboard/SERVICE_ACCOUNT.json`. Stretch goal: move Calendar over to **mackinaw.canopy@gmail.com** as well (e.g. same project, new service account for that account, or OAuth) so one identity backs Calendar, Photos, and Gmail. ### Other From b9c0e895b9f2f338a74344f3d8cd11d6f8425227 Mon Sep 17 00:00:00 2001 From: ManliestBen Date: Wed, 18 Feb 2026 17:01:19 -0600 Subject: [PATCH 3/5] implement send mail functionality, plan pivot for lack of google photo API functionality --- dashboard/package.json | 4 +- dashboard/scripts/oauth-get-tokens.js | 144 -------------------------- dashboard/scripts/send-test-email.js | 80 ++++++++++++++ 3 files changed, 82 insertions(+), 146 deletions(-) delete mode 100644 dashboard/scripts/oauth-get-tokens.js create mode 100644 dashboard/scripts/send-test-email.js diff --git a/dashboard/package.json b/dashboard/package.json index fc47324..ba159d8 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -11,8 +11,8 @@ "preview": "vite preview", "serve": "nodemon server.js", "test": "vitest run", -"test:watch": "vitest", - "oauth-get-tokens": "node scripts/oauth-get-tokens.js" + "test:watch": "vitest", + "send-test-email": "node scripts/send-test-email.js" }, "dependencies": { "better-sqlite3": "^12.6.2", diff --git a/dashboard/scripts/oauth-get-tokens.js b/dashboard/scripts/oauth-get-tokens.js deleted file mode 100644 index 021003e..0000000 --- a/dashboard/scripts/oauth-get-tokens.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * One-time OAuth flow to get a refresh token for the Canopy Gmail user - * (Photos Library, Photos Ambient, Picker, Gmail, Calendar). - * - * Usage (interactive – script starts a server and opens the browser): - * 1. Put your OAuth client JSON at dashboard/oauth-credentials.json - * 2. In Google Cloud Console, set authorized redirect URI to exactly: http://localhost:3000/oauth2callback - * 3. From dashboard/: npm install && npm run oauth-get-tokens - * 4. Sign in, approve scopes; you’ll be redirected to localhost:3000 and the script will print the token. - * - * Usage (manual – you already have the code from a redirect to e.g. http://localhost/?code=...): - * npm run oauth-get-tokens -- "PASTE_THE_CODE_HERE" - * If Google sent you to http://localhost/ (no port), use: - * npm run oauth-get-tokens -- "PASTE_THE_CODE_HERE" --redirect-uri "http://localhost/" - */ - -import { createServer } from 'http'; -import { readFileSync } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { google } from 'googleapis'; -import open from 'open'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const REDIRECT_PORT = 3000; -const REDIRECT_PATH = '/oauth2callback'; -const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; - -const SCOPES = [ - 'https://www.googleapis.com/auth/photoslibrary.readonly', - 'https://www.googleapis.com/auth/photosambient.mediaitems', - 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly', - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/calendar.readonly', - 'https://www.googleapis.com/auth/calendar', -]; - -// Resolve credentials path: from dashboard/scripts/ -> dashboard/oauth-credentials.json -const credentialsPath = path.join(__dirname, '..', 'oauth-credentials.json'); -let credentials; -try { - credentials = JSON.parse(readFileSync(credentialsPath, 'utf8')); -} catch (err) { - console.error('Failed to load oauth-credentials.json from', credentialsPath); - console.error('Download the OAuth client JSON from Google Cloud Console and save it there.'); - process.exit(1); -} - -const client = credentials.web || credentials.installed; -if (!client || !client.client_id || !client.client_secret) { - console.error('oauth-credentials.json must contain .web or .installed with client_id and client_secret.'); - process.exit(1); -} - -// Optional: run in manual code mode (code passed as first arg; optional --redirect-uri) -const args = process.argv.slice(2); -const manualCode = args.find((a) => !a.startsWith('--')); -const redirectUriArg = args.find((a) => a.startsWith('--redirect-uri=')); -const manualRedirectUri = redirectUriArg ? redirectUriArg.split('=')[1] : null; - -async function exchangeCode(code, redirectUri) { - const oauth2Client = new google.auth.OAuth2( - client.client_id, - client.client_secret, - redirectUri - ); - const { tokens } = await oauth2Client.getToken(code); - console.log('\n--- Store this in dashboard/.env (do not commit) ---\n'); - console.log('GOOGLE_OAUTH_REFRESH_TOKEN=' + (tokens.refresh_token || '')); - console.log('\n--- End ---\n'); - if (!tokens.refresh_token) { - console.warn('No refresh_token in response. You may need to revoke app access and run again with prompt=consent.'); - } -} - -if (manualCode) { - const redirectUri = manualRedirectUri || 'http://localhost/'; - exchangeCode(manualCode, redirectUri) - .then(() => process.exit(0)) - .catch((err) => { - console.error('Failed to exchange code for tokens:', err.message); - if (err.message?.includes('redirect_uri')) { - console.error('Try passing the exact redirect URI Google used, e.g.: --redirect-uri "http://localhost/"'); - } - process.exit(1); - }); -} else { - -const oauth2Client = new google.auth.OAuth2( - client.client_id, - client.client_secret, - client.redirect_uris?.[0] || REDIRECT_URI -); - -const authUrl = oauth2Client.generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: SCOPES, -}); - -const server = createServer(async (req, res) => { - const reqUrl = req.url || ''; - const isCallback = reqUrl.startsWith(REDIRECT_PATH) || (reqUrl.startsWith('/') && reqUrl.includes('code=')); - if (!isCallback) { - res.writeHead(404).end('Not found'); - return; - } - const qs = new URL(reqUrl, `http://localhost:${REDIRECT_PORT}`).searchParams; - const code = qs.get('code'); - const error = qs.get('error'); - if (error) { - res.writeHead(200).end(`Authorization failed: ${error}. Check the terminal for details.`); - server.close(); - console.error('OAuth error:', error, qs.get('error_description') || ''); - process.exit(1); - } - if (!code) { - res.writeHead(200).end('No code in callback. Try again.'); - server.close(); - process.exit(1); - } - res.writeHead(200, { 'Content-Type': 'text/html' }).end( - '

Success. You can close this tab.

Check the terminal for the refresh token.

' - ); - server.close(); - try { - await exchangeCode(code, client.redirect_uris?.[0] || REDIRECT_URI); - } catch (err) { - console.error('Failed to exchange code for tokens:', err.message); - process.exit(1); - } -}); - - server.listen(REDIRECT_PORT, () => { - console.log('Opening browser for Google sign-in. Sign in as your Canopy Gmail (e.g. mackinaw.canopy@gmail.com).\n'); - console.log('If the browser did not open, visit this URL:\n'); - console.log(authUrl); - console.log(''); - open(authUrl).catch(() => { - console.log('Could not open browser automatically. Copy the URL above into your browser.'); - }); - }); -} diff --git a/dashboard/scripts/send-test-email.js b/dashboard/scripts/send-test-email.js new file mode 100644 index 0000000..c29815b --- /dev/null +++ b/dashboard/scripts/send-test-email.js @@ -0,0 +1,80 @@ +/** + * Send a test email via Gmail API using OAuth2 (refresh token in .env). + * Requires: dashboard/oauth-credentials.json and GOOGLE_OAUTH_REFRESH_TOKEN in .env. + * Gmail API must be enabled in the Google Cloud project; OAuth consent must include gmail.send. + * + * Run from dashboard: npm run send-test-email + */ + +import { google } from 'googleapis'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env from dashboard root +dotenv.config({ path: path.join(__dirname, '..', '.env') }); + +const TO_EMAIL = 'benjamintmanley@gmail.com'; +const SUBJECT = "Hey there!"; +const BODY = "How's it going?"; + +function loadOAuthCredentials() { + const credPath = path.join(__dirname, '..', 'oauth-credentials.json'); + const raw = readFileSync(credPath, 'utf8'); + const json = JSON.parse(raw); + const client = json.installed || json.web; + if (!client || !client.client_id || !client.client_secret) { + throw new Error('oauth-credentials.json must contain installed or web with client_id and client_secret'); + } + return { + clientId: client.client_id, + clientSecret: client.client_secret, + redirectUri: client.redirect_uris?.[0] || 'http://localhost:3000/oauth2callback', + }; +} + +function buildRawMessage(to, subject, body) { + const lines = [ + `To: ${to}`, + `Subject: ${subject}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + body, + ]; + const message = lines.join('\n'); + return Buffer.from(message) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +async function main() { + const refreshToken = process.env.GOOGLE_OAUTH_REFRESH_TOKEN; + if (!refreshToken) { + console.error('Missing GOOGLE_OAUTH_REFRESH_TOKEN in .env'); + process.exit(1); + } + + const { clientId, clientSecret, redirectUri } = loadOAuthCredentials(); + const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri); + oauth2Client.setCredentials({ refresh_token: refreshToken }); + + const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + const raw = buildRawMessage(TO_EMAIL, SUBJECT, BODY); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw }, + }); + + console.log(`Test email sent to ${TO_EMAIL} (subject: "${SUBJECT}").`); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); From d9217bfcfc4ea4f21ef056a8efe79591062346a2 Mon Sep 17 00:00:00 2001 From: ManliestBen Date: Wed, 18 Feb 2026 17:03:35 -0600 Subject: [PATCH 4/5] add test for gmail send functionality --- dashboard/lib/email.js | 27 ++++++++++++++++++++ dashboard/scripts/send-test-email.js | 17 +------------ dashboard/src/email.test.ts | 37 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 dashboard/lib/email.js create mode 100644 dashboard/src/email.test.ts diff --git a/dashboard/lib/email.js b/dashboard/lib/email.js new file mode 100644 index 0000000..875b5ce --- /dev/null +++ b/dashboard/lib/email.js @@ -0,0 +1,27 @@ +/** + * Helpers for building Gmail API raw messages (base64url-encoded RFC 2822). + * Used by scripts/send-test-email.js and testable without I/O. + */ + +/** + * Build a raw MIME message string and return it base64url-encoded for Gmail API. + * @param {string} to - Recipient email address + * @param {string} subject - Subject line + * @param {string} body - Plain text body + * @returns {string} base64url-encoded message for requestBody.raw + */ +export function buildRawMessage(to, subject, body) { + const lines = [ + `To: ${to}`, + `Subject: ${subject}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + body, + ]; + const message = lines.join('\n'); + return Buffer.from(message) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} diff --git a/dashboard/scripts/send-test-email.js b/dashboard/scripts/send-test-email.js index c29815b..60a28d2 100644 --- a/dashboard/scripts/send-test-email.js +++ b/dashboard/scripts/send-test-email.js @@ -11,6 +11,7 @@ import { readFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; +import { buildRawMessage } from '../lib/email.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -36,22 +37,6 @@ function loadOAuthCredentials() { }; } -function buildRawMessage(to, subject, body) { - const lines = [ - `To: ${to}`, - `Subject: ${subject}`, - 'Content-Type: text/plain; charset=UTF-8', - '', - body, - ]; - const message = lines.join('\n'); - return Buffer.from(message) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - async function main() { const refreshToken = process.env.GOOGLE_OAUTH_REFRESH_TOKEN; if (!refreshToken) { diff --git a/dashboard/src/email.test.ts b/dashboard/src/email.test.ts new file mode 100644 index 0000000..da7ecca --- /dev/null +++ b/dashboard/src/email.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { buildRawMessage } from '../lib/email.js'; + +describe('buildRawMessage', () => { + it('returns base64url-encoded string (no + / or trailing =)', () => { + const raw = buildRawMessage('a@b.com', 'Sub', 'Body'); + expect(raw).not.toContain('+'); + expect(raw).not.toContain('/'); + expect(raw).not.toMatch(/=+$/); + expect(typeof raw).toBe('string'); + expect(raw.length).toBeGreaterThan(0); + }); + + it('decodes to RFC 2822 with To, Subject, Content-Type and body', () => { + const raw = buildRawMessage('ben@example.com', 'Hey there!', "How's it going?"); + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + expect(decoded).toContain('To: ben@example.com'); + expect(decoded).toContain('Subject: Hey there!'); + expect(decoded).toContain('Content-Type: text/plain; charset=UTF-8'); + expect(decoded).toContain("How's it going?"); + }); + + it('handles empty body', () => { + const raw = buildRawMessage('x@y.z', 'No body', ''); + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + expect(decoded).toContain('To: x@y.z'); + expect(decoded).toContain('Subject: No body'); + expect(decoded.endsWith('\n\n')).toBe(true); + }); + + it('handles multiline body', () => { + const body = 'Line 1\nLine 2'; + const raw = buildRawMessage('a@b.c', 'Sub', body); + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + expect(decoded).toContain('Line 1\nLine 2'); + }); +}); From 89fc7b926e9faecabd2c0c61e82e79567a93883f Mon Sep 17 00:00:00 2001 From: ManliestBen Date: Wed, 18 Feb 2026 17:03:56 -0600 Subject: [PATCH 5/5] add test for gmail send functionality --- ...gle-apis-photos-and-notifications-setup.md | 223 ------------------ feature-list/feature-list.md | 24 +- 2 files changed, 12 insertions(+), 235 deletions(-) delete mode 100644 docs/google-apis-photos-and-notifications-setup.md diff --git a/docs/google-apis-photos-and-notifications-setup.md b/docs/google-apis-photos-and-notifications-setup.md deleted file mode 100644 index b03a5aa..0000000 --- a/docs/google-apis-photos-and-notifications-setup.md +++ /dev/null @@ -1,223 +0,0 @@ -# Google APIs setup: Photos & notifications (Gmail) - -This guide walks through configuring **Google Photos Library API** (for the photo slideshow) and **Gmail API** (for sending notifications, reminders, or announcements from the Canopy Gmail account). You can use the **same Google Cloud project** you already use for Calendar. - -**Auth model:** - -| Feature | Auth | Why | -|--------|------|-----| -| **Calendar** | Service account (`SERVICE_ACCOUNT.json`) | Currently a separate Gmail/account; you share calendars with that service account email. Stretch goal: move to **mackinaw.canopy@gmail.com** (see feature list). | -| **Photos** | OAuth 2.0 (user = Canopy Gmail) | Photos API does not support service accounts. You sign in once as the Canopy Gmail user (**mackinaw.canopy@gmail.com**); family shares albums with that account. | -| **Gmail (send)** | OAuth 2.0 (user = Canopy Gmail) | Gmail API cannot send mail via service account; you authorize the app to send as the Canopy Gmail user. | - ---- - -## Prerequisites - -- A **Google Cloud project** (the one you use for Calendar, or create a new one). -- A **Gmail account for Canopy** (**mackinaw.canopy@gmail.com**) that will own the OAuth tokens for Photos and Gmail. (Calendar stays on its current service account for now.) -- For **Photos**: Family members share Google Photos albums with this Canopy Gmail account so the app can display them in the slideshow. - ---- - -## Part 1: One-time Google Cloud setup - -### 1.1 Open your project - -1. Go to [Google Cloud Console](https://console.cloud.google.com/). -2. Select the **project** you use for Canopy Calendar (or create a new project and note its name). - -### 1.2 Enable the APIs - -1. In the left menu go to **APIs & Services** → **Library**. -2. Search for and enable: - - **Google Photos Library API** — for listing and reading albums/photos for the slideshow. - - **Google Photos Ambient API** — for ambient/slideshow devices ([reference](https://developers.google.com/photos/ambient/reference/rest)); used by `mediaItems.list` for user-configured media sources. - - **Google Photos Picker API** — for letting users pick photos from their library (e.g. in a web or app flow). - - **Gmail API** — for sending emails (reminders, announcements) from the Canopy account. - - **Google Calendar API** — for Calendar (already enabled if you use Calendar today; needed for OAuth when you consolidate to mackinaw.canopy@gmail.com). - -Enabling these up front avoids re-running the OAuth consent flow when you add features later. - -### 1.3 OAuth consent screen - -Photos and Gmail use **OAuth 2.0** (user login), so you need a consent screen. You’ll authorize the app once as the Canopy Gmail user. - -1. Go to **APIs & Services** → **OAuth consent screen**. -2. Choose **External** (for a personal Gmail like `canopy@gmail.com`) or **Internal** (only if the Canopy account is in a Google Workspace org). -3. Click **Create** (or edit if it already exists). -4. Fill in: - - **App name:** e.g. `Canopy` - - **User support email:** your email - - **Developer contact:** your email -5. Click **Save and Continue**. -6. **Scopes:** - - Click **Add or Remove Scopes**. - - Add the following so you don’t have to re-consent later when you add features: - - | Scope | Purpose | - |-------|---------| - | `https://www.googleapis.com/auth/photoslibrary.readonly` | Photos Library API: read albums and media (slideshow). | - | `https://www.googleapis.com/auth/photosambient.mediaitems` | [Photos Ambient API](https://developers.google.com/photos/ambient/reference/rest): list ambient media items for a device ([mediaItems.list](https://developers.google.com/photos/ambient/reference/rest/v1/mediaItems/list)). | - | `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` | Photos Picker API: create sessions and list media items for picker flows. | - | `https://www.googleapis.com/auth/gmail.send` | Gmail API: send email as the signed-in user (notifications). | - | `https://www.googleapis.com/auth/calendar.readonly` | Calendar API: read events (for when you consolidate Calendar to OAuth / mackinaw.canopy@gmail.com). | - | `https://www.googleapis.com/auth/calendar` | Calendar API: create/edit/delete events (same consolidation case). | - - - Remove any you truly don’t need, then Save. -7. **Test users** (if app is in “Testing”): - - Add **mackinaw.canopy@gmail.com** so that account can sign in during testing. -8. **Save and Continue** through the summary. - -### 1.4 Create OAuth 2.0 credentials - -1. Go to **APIs & Services** → **Credentials**. -2. Click **Create Credentials** → **OAuth client ID**. -3. **Application type:** - - **Desktop app** — if the app runs on your machine (e.g. dev or a Pi) and you can open a browser once to sign in. - - **Web application** — if the app has a web server and you’ll use a redirect URL (e.g. `http://localhost:3000/oauth2callback`). -4. Name it (e.g. `Canopy Photos & Gmail`). -5. If you chose **Web application**, add an **Authorized redirect URI** (e.g. `http://localhost:5173/oauth2callback` for local dev). -6. Click **Create**. -7. Download the JSON: click the download icon next to the new OAuth client. Save it somewhere safe (e.g. `dashboard/oauth-credentials.json`). - - **Do not commit this file.** The repo’s `.gitignore` already ignores `*-credentials.json`. - -You should see a **Client ID** and **Client secret** in the JSON. The app will use these plus a **refresh token** (obtained in Part 2) to get access tokens for Photos and Gmail. - ---- - -## Part 2: First-time sign-in (refresh token for Canopy Gmail) - -The app needs a **refresh token** for the Canopy Gmail user so it can call Photos and Gmail without opening a browser every time. You do this once per environment (dev machine, production server). - -### 2.1 Run a one-time auth flow - -You need a small script or your app to: - -1. Open a browser to Google’s OAuth URL with your client ID and scopes. -2. Have the **Canopy Gmail user** sign in and approve the requested scopes (Photos, Ambient, Picker, Gmail, and Calendar if you added them). -3. Receive the authorization code (via redirect or copy from the browser). -4. Exchange the code for **access_token** and **refresh_token**. -5. Store the **refresh_token** securely (env var or a file that is not committed). - -**Scopes to request** (match what you added in the consent screen): - -- `https://www.googleapis.com/auth/photoslibrary.readonly` -- `https://www.googleapis.com/auth/photosambient.mediaitems` -- `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` -- `https://www.googleapis.com/auth/gmail.send` -- `https://www.googleapis.com/auth/calendar.readonly` -- `https://www.googleapis.com/auth/calendar` - -**Ready-made script:** - -A one-time script is in the repo. Use it like this: - -1. Download your OAuth client JSON from Google Cloud Console and save it as **`dashboard/oauth-credentials.json`**. -2. In the OAuth client settings, add **`http://localhost:3000/oauth2callback`** as an authorized redirect URI (Web application type). -3. From the **dashboard** directory: - - ```bash - cd dashboard - npm install - npm run oauth-get-tokens - ``` - -4. A browser opens; sign in as **mackinaw.canopy@gmail.com** (or your Canopy Gmail) and approve the scopes. -5. The terminal prints `GOOGLE_OAUTH_REFRESH_TOKEN=...`. Copy that line into **`dashboard/.env`**. - -**Important:** The OAuth client’s redirect URI in Google Cloud Console must be **exactly** `http://localhost:3000/oauth2callback` (port 3000, path `/oauth2callback`). If it’s set to `http://localhost/` (no port), Google will redirect there and the script’s server won’t receive the code — you’ll see “localhost refused to connect” because nothing is listening on port 80. Fix the redirect URI in the console and run the script again, or use the manual option below. - -**If you already got redirected to `http://localhost/?code=...`** (and the page failed to load), you can still get a refresh token without redoing the flow. From **dashboard/** run (paste your code in place of `CODE`): - -```bash -npm run oauth-get-tokens -- "CODE" --redirect-uri "http://localhost/" -``` - -Use the exact redirect URI Google used (e.g. `http://localhost/` with or without trailing slash). The script will exchange the code for tokens and print `GOOGLE_OAUTH_REFRESH_TOKEN=...`. - -The script is **`dashboard/scripts/oauth-get-tokens.js`**. It uses the dashboard’s existing `googleapis` and the `open` package (already in `package.json`) to open the browser. - -### 2.2 Store the refresh token - -- **Development:** Put the refresh token in `.env` (do not commit): - - ```env - GOOGLE_OAUTH_REFRESH_TOKEN=your_refresh_token_here - ``` - -- **Production:** Use the same env var or your secrets manager. Never commit the refresh token. - ---- - -## Part 3: App configuration - -### 3.1 OAuth credentials file - -- Keep the downloaded OAuth client JSON as e.g. `dashboard/oauth-credentials.json`. -- Ensure it’s in `.gitignore`: - - ``` - oauth-credentials.json - .env - SERVICE_ACCOUNT.json - ``` - -### 3.2 Environment variables - -In `dashboard/.env` (or production env): - -```env -# Existing -VITE_HA_BASE_URL=... -VITE_HA_TOKEN=... - -# Photos & Gmail (OAuth) -GOOGLE_OAUTH_REFRESH_TOKEN=... # from Part 2 -# Optional: path to oauth client JSON if not default -# GOOGLE_OAUTH_CREDENTIALS_PATH=dashboard/oauth-credentials.json -``` - -Your app code will: - -1. Load the OAuth client ID and secret from `oauth-credentials.json`. -2. Use `GOOGLE_OAUTH_REFRESH_TOKEN` to get an access token when calling Photos or Gmail APIs. -3. Use that access token for Photos Library, Photos Ambient, Photos Picker, Gmail send, and (when implemented) Calendar. - -### 3.3 Photos: sharing albums with the Canopy account - -- Have family members **share** their Google Photos albums with the **Canopy Gmail address** (the one you used to get the refresh token). -- In the app, when you call the Photos Library API as that user, you’ll see: - - Albums owned by that account. - - Albums shared with that account (shared albums appear in the list). -- Use the existing APIs (e.g. `albums.list`, `mediaItems.search`) to drive the slideshow from those albums. - -### 3.4 Gmail: sending notifications - -- With the same refresh token and `gmail.send` scope, your app can call the Gmail API `messages.send` to send email as the Canopy Gmail user. -- Use this for: event reminders, “Dinner’s ready”–style announcements, or daily digest emails, so notifications come from a consistent Canopy identity. - ---- - -## Part 4: Security checklist - -- [ ] **OAuth client JSON** (`oauth-credentials.json`) is in `.gitignore` and never committed. -- [ ] **Refresh token** is only in `.env` or a secrets store, never in code or Git. -- [ ] **Service account key** (`SERVICE_ACCOUNT.json`) remains only for Calendar and is not committed. -- [ ] If the refresh token is ever exposed, revoke it in [Google Account → Security → Third-party access](https://myaccount.google.com/permissions) and run the Part 2 flow again to get a new one. - ---- - -## Summary - -| Item | Where / How | -|------|-------------| -| Google Cloud project | Same as Calendar (or new project). | -| APIs enabled | Photos Library API, Gmail API (Calendar already enabled). | -| OAuth consent screen | External (or Internal for Workspace); scopes: `photoslibrary.readonly`, `photosambient.mediaitems`, `photospicker.mediaitems.readonly`, `gmail.send`, `calendar.readonly`, `calendar`. | -| OAuth client | Create OAuth client ID (Desktop or Web), download JSON → `oauth-credentials.json`. | -| Refresh token | One-time browser sign-in as Canopy Gmail → store in `GOOGLE_OAUTH_REFRESH_TOKEN`. | -| Photos | Family shares albums with Canopy Gmail; app uses OAuth to list/read via Photos Library API. | -| Gmail | App uses same OAuth (refresh token) to send mail via Gmail API as Canopy Gmail. | - -After this, your app can use the Photos Library API for the slideshow and the Gmail API for notifications without storing the Canopy Gmail password; only the refresh token and OAuth client credentials are needed on the server. diff --git a/feature-list/feature-list.md b/feature-list/feature-list.md index 468954b..493ef18 100644 --- a/feature-list/feature-list.md +++ b/feature-list/feature-list.md @@ -61,10 +61,9 @@ A combination of **Home Assistant**–style smart home control and **Skylight** - [ ] **Sleep/wake times** — dim or switch to slideshow (see clarifications) - [ ] **Photo slideshow** — starts after a configurable delay with no interaction, or as sleep mode - - [ ] **Primary source:** **Google Photos** — users share albums with the Canopy Gmail account; slideshow uses those albums via Photos Library API (see Input & tradeoffs). No Cloudinary work yet. - - [ ] **Configurable album/folder selection** — choose which shared albums (or later, folders) feed the slideshow. - - [ ] **Cloudinary** — on the back burner. Not written off: fast, responsive, 10GB more storage than Google; may be revisited for uploads, transforms, or as an alternative source. - - [ ] Starter set can be provided (e.g. in a Google Photos album shared with Canopy, or local until Google is wired up). + - [ ] **Primary source:** **Cloudinary** — use Cloudinary for slideshow media (see Input & tradeoffs). Google Photos was considered but would require device registration, user linking in Google Photos app, and API limits; Cloudinary is simpler to integrate. **CLOUDINARY_URL** is already in `dashboard/.env` for when we wire this up. + - [ ] **Configurable album/folder selection** — choose which folders or collections feed the slideshow (Cloudinary folders/tags or similar). + - [ ] Starter set can be provided (e.g. local assets or a default Cloudinary folder until configured). ### Task list (nav) @@ -139,16 +138,17 @@ A combination of **Home Assistant**–style smart home control and **Skylight** ### Canopy service Gmail account -- [ ] **Dedicated Gmail** — A Gmail account has been created for the Canopy service (**mackinaw.canopy@gmail.com**). Potential uses (no code yet; ideas for the roadmap): +- [x] **Send email** — We can send email via Gmail API using the Canopy Gmail account (**mackinaw.canopy@gmail.com**) and OAuth (refresh token in `.env`). Test script: `npm run send-test-email` from dashboard. Use this as we add reminders, announcements, and other outbound features. +- [ ] **Dedicated Gmail (other uses)** — Potential uses to build on the above: - **Outbound:** Event or task reminders, family announcements (“Dinner’s ready”), daily agenda digest, or “what’s on the panel today” emails. - **Inbound:** “Email to add” flows (e.g. forward an email to create a task or quick event), or invite/guest-access links sent by email. - - **Auth:** Sending from the panel via OAuth or app password for the above. Notifications could be sent through this account so they come from a consistent “Canopy” identity. -- [ ] **Stretch: Consolidate Calendar to Canopy Gmail** — Calendar currently uses a different Gmail account and `dashboard/SERVICE_ACCOUNT.json`. Stretch goal: move Calendar over to **mackinaw.canopy@gmail.com** as well (e.g. same project, new service account for that account, or OAuth) so one identity backs Calendar, Photos, and Gmail. + - Notifications from this account so they come from a consistent “Canopy” identity. +- [ ] **Stretch: Consolidate Calendar to Canopy Gmail** — Calendar currently uses a different Gmail account and `dashboard/SERVICE_ACCOUNT.json`. Stretch goal: move Calendar over to **mackinaw.canopy@gmail.com** as well (e.g. same project, new service account for that account, or OAuth) so one identity backs Calendar and Gmail. ### Other - [ ] **Energy / utility** — small widget or view for energy/solar (e.g. today’s production/usage) when using HA energy -- [ ] **Photo source options** — **Google Photos (shared albums with Canopy)** first; **Cloudinary on the back burner** (fast, responsive, more storage; future option). Local/NAS path or combination also on the roadmap. +- [ ] **Photo source options** — **Cloudinary** as primary for slideshow (see Input & tradeoffs). Local/NAS path or combination also on the roadmap. - [ ] **Vacation / away mode** — pause or simplify slideshow; minimal screen or reduced HA sensitivity when “away” - [ ] **Multi-device / naming** — device name and optional role (e.g. kitchen vs bedroom) for settings and backups - [ ] **Responsive design** for mobile app usage @@ -157,8 +157,8 @@ A combination of **Home Assistant**–style smart home control and **Skylight** ## Input & tradeoffs (no code action) -### Photo source: Google Photos first, Cloudinary on the back burner +### Photo source: Cloudinary (Google Photos removed) -- **Current plan:** Use **Google Photos** for the slideshow: family shares albums with the Canopy Gmail account; the app uses the Photos Library API to list and display those albums. Lower friction when the family already uses Google Photos. -- **Cloudinary** is **on the back burner**, not written off: it's fast, responsive, and offers ~10GB more storage than Google. Could be revisited later for uploads, transforms, configurable folders, or as an alternative/extra source. No code planned for Cloudinary until after Google Photos is in place. -- **Summary:** Google Photos first (shared albums → slideshow). Cloudinary remains a future option for more control, storage, and performance. +- **Decision:** Use **Cloudinary** for the slideshow instead of Google Photos. Google Photos would have required the Ambient API (device registration, user linking in the Google Photos app, 240 requests/device/day limit) or other workarounds after the Library API was restricted in 2025 — too many hoops for the goal of “users easily share albums with the calendar.” +- **Cloudinary** is the primary photo source: simpler integration, fast, responsive, and sufficient storage. Configurable folders/collections can feed the slideshow. +- **Summary:** Slideshow is backed by Cloudinary. Google Photos–related scripts and setup docs have been removed from the repo.