Skip to content

Commit 7b561a1

Browse files
committed
update workspace from account
1 parent 7f4efb0 commit 7b561a1

File tree

6 files changed

+189
-60
lines changed

6 files changed

+189
-60
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict';
2+
3+
const extrovert = require('extrovert');
4+
5+
module.exports = extrovert.toNetlifyFunction(require('../../src/actions/updateWorkspace'));

public/login.html

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
<h1 class="text-3xl font-bold tracking-tight">Set up your account</h1>
1313
<p id="message" class="mt-2 text-slate-600">Sign in to connect your account and access your API key.</p>
1414

15-
<div class="mt-6 grid gap-3">
16-
<button id="githubButton" type="button" class="w-full rounded-lg bg-[#880000] px-4 py-3 text-sm font-semibold text-white transition hover:bg-[#6d0000] disabled:cursor-not-allowed disabled:opacity-60">Continue with GitHub</button>
17-
<button id="googleButton" type="button" class="w-full rounded-lg bg-[#880000] px-4 py-3 text-sm font-semibold text-white transition hover:bg-[#6d0000] disabled:cursor-not-allowed disabled:opacity-60">Continue with Google</button>
15+
<div class="mt-6 grid grid-cols-2 gap-3">
16+
<button id="githubButton" type="button" class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60">
17+
<svg viewBox="0 0 98 98" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#111827"/></svg>
18+
Continue with GitHub
19+
</button>
20+
<button id="googleButton" type="button" class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60">
21+
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true"><path fill="#EA4335" d="M256 212v88h146c-6 38-22 66-47 85l76 59c44-40 69-99 69-169 0-16-1-28-4-40z"/><path fill="#34A853" d="M256 512c68 0 126-22 168-59l-76-59c-21 14-49 24-92 24-70 0-129-47-150-111l-79 61c41 81 125 144 229 144z"/><path fill="#4A90E2" d="M106 307c-5-14-8-30-8-47s3-33 8-47l-79-61C10 185 0 221 0 260s10 75 27 108z"/><path fill="#FBBC05" d="M256 102c37 0 70 13 96 38l72-72C382 28 324 0 256 0 152 0 68 63 27 152l79 61c21-64 80-111 150-111z"/></svg>
22+
Continue with Google
23+
</button>
1824
</div>
1925

2026
<p class="mt-4">

public/my-account.html

Lines changed: 114 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,37 @@ <h1 class="text-3xl font-bold tracking-tight">My Account</h1>
2727
</section>
2828
</main>
2929

30+
<template id="emptyStateTemplate">
31+
<div class="rounded-lg border border-dashed border-slate-300 p-4 text-slate-600">No workspaces found for this account.</div>
32+
</template>
33+
34+
<template id="workspaceTemplate">
35+
<section class="rounded-xl border border-slate-200 p-4">
36+
<div class="flex flex-wrap items-start justify-between gap-3">
37+
<div>
38+
<div class="inline-flex items-center gap-2">
39+
<div class="text-lg font-semibold" data-workspace-name></div>
40+
<button type="button" class="inline-flex items-center rounded-md border border-slate-300 bg-white p-1 text-slate-700 hover:bg-slate-50" data-edit-name aria-label="Edit workspace name">
41+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-4 w-4"><path d="M3 17.25V21h3.75L17.8 9.94l-3.75-3.75L3 17.25Zm18-11.5a1 1 0 0 0 0-1.41l-1.34-1.34a1 1 0 0 0-1.41 0l-1.18 1.18 3.75 3.75L21 5.75Z"/></svg>
42+
</button>
43+
</div>
44+
<div class="text-sm text-slate-600">Workspace ID: <span data-workspace-id-label></span></div>
45+
</div>
46+
<div class="flex flex-wrap gap-2 text-xs">
47+
<span class="rounded-full border border-slate-300 px-2 py-1 text-slate-700">Tier: <span data-tier-label></span></span>
48+
<span class="rounded-full border border-slate-300 px-2 py-1 text-slate-700">Roles: <span data-roles-label></span></span>
49+
</div>
50+
</div>
51+
<div class="mt-3 flex flex-wrap items-center gap-2">
52+
<code class="max-w-full break-all rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs sm:text-sm" data-key-view></code>
53+
<button type="button" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:bg-slate-50" data-toggle-key data-visible="false" aria-label="Show API key">
54+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-4 w-4"><path d="M12 5c-5.5 0-9.5 4.5-10.8 6.2a1.3 1.3 0 0 0 0 1.6C2.5 14.5 6.5 19 12 19s9.5-4.5 10.8-6.2a1.3 1.3 0 0 0 0-1.6C21.5 9.5 17.5 5 12 5Zm0 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8Z"/></svg>
55+
</button>
56+
<button type="button" class="rounded-lg bg-[#880000] px-3 py-2 text-xs font-semibold text-white transition hover:bg-[#6d0000]" data-copy-key>Copy API Key</button>
57+
</div>
58+
</section>
59+
</template>
60+
3061
<script>
3162
(function() {
3263
const ACCESS_TOKEN_KEY = '_mongooseStudioAccessToken';
@@ -69,77 +100,117 @@ <h1 class="text-3xl font-bold tracking-tight">My Account</h1>
69100
const workspaces = data.workspaces || [];
70101

71102
userSummary.textContent = user.email ? user.email : 'Signed in';
103+
workspaceList.innerHTML = '';
72104

73105
if (workspaces.length === 0) {
74-
workspaceList.innerHTML = '<div class="rounded-lg border border-dashed border-slate-300 p-4 text-slate-600">No workspaces found for this account.</div>';
106+
workspaceList.appendChild(createElementFromTemplate('emptyStateTemplate'));
75107
return;
76108
}
77109

78-
workspaceList.innerHTML = '';
79110
for (const workspace of workspaces) {
80-
const item = document.createElement('section');
81-
item.className = 'rounded-xl border border-slate-200 p-4';
111+
const item = createElementFromTemplate('workspaceTemplate');
112+
item.setAttribute('data-workspace-id', String(workspace._id));
82113

83114
const roles = Array.isArray(workspace.roles) && workspace.roles.length ? workspace.roles.join(', ') : 'none';
84115
const apiKey = String(workspace.apiKey || '');
85116
const maskedApiKey = apiKey ? '\u2022'.repeat(Math.max(8, apiKey.length)) : '';
117+
const canEditName = Array.isArray(workspace.roles) && (workspace.roles.includes('owner') || workspace.roles.includes('admin'));
118+
119+
item.querySelector('[data-workspace-name]').textContent = workspace.name || 'Unnamed Workspace';
120+
item.querySelector('[data-workspace-id-label]').textContent = String(workspace._id);
121+
item.querySelector('[data-tier-label]').textContent = workspace.subscriptionTier || 'unknown';
122+
item.querySelector('[data-roles-label]').textContent = roles;
123+
124+
const editButton = item.querySelector('[data-edit-name]');
125+
if (!canEditName) {
126+
editButton.remove();
127+
}
128+
129+
const keyView = item.querySelector('[data-key-view]');
130+
keyView.textContent = maskedApiKey;
86131

87-
item.innerHTML = [
88-
'<div class="flex flex-wrap items-start justify-between gap-3">',
89-
'<div>',
90-
'<div class="text-lg font-semibold">' + escapeHtml(workspace.name || 'Unnamed Workspace') + '</div>',
91-
'<div class="text-sm text-slate-600">Workspace ID: ' + escapeHtml(String(workspace._id)) + '</div>',
92-
'</div>',
93-
'<div class="flex flex-wrap gap-2 text-xs">',
94-
'<span class="rounded-full border border-slate-300 px-2 py-1 text-slate-700">Tier: ' + escapeHtml(workspace.subscriptionTier || 'unknown') + '</span>',
95-
'<span class="rounded-full border border-slate-300 px-2 py-1 text-slate-700">Roles: ' + escapeHtml(roles) + '</span>',
96-
'</div>',
97-
'</div>',
98-
'<div class="mt-3 flex flex-wrap items-center gap-2">',
99-
'<code class="max-w-full break-all rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs sm:text-sm" data-key-view>' + escapeHtml(maskedApiKey) + '</code>',
100-
'<button type="button" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:bg-slate-50" data-toggle-key data-key="' + escapeHtml(apiKey) + '" data-masked="' + escapeHtml(maskedApiKey) + '" data-visible="false" aria-label="Show API key">',
101-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-4 w-4"><path d="M12 5c-5.5 0-9.5 4.5-10.8 6.2a1.3 1.3 0 0 0 0 1.6C2.5 14.5 6.5 19 12 19s9.5-4.5 10.8-6.2a1.3 1.3 0 0 0 0-1.6C21.5 9.5 17.5 5 12 5Zm0 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8Z"/></svg>',
102-
'</button>',
103-
'<button type="button" class="rounded-lg bg-[#880000] px-3 py-2 text-xs font-semibold text-white transition hover:bg-[#6d0000]" data-copy-key="' + escapeHtml(apiKey) + '">Copy API Key</button>',
104-
'</div>'
105-
].join('');
132+
const toggleButton = item.querySelector('[data-toggle-key]');
133+
toggleButton.setAttribute('data-key', apiKey);
134+
toggleButton.setAttribute('data-masked', maskedApiKey);
135+
136+
const copyButton = item.querySelector('[data-copy-key]');
137+
copyButton.setAttribute('data-copy-key', apiKey);
106138

107139
workspaceList.appendChild(item);
108-
}
109140

110-
workspaceList.querySelectorAll('button[data-copy-key]').forEach(function(button) {
111-
button.addEventListener('click', async function() {
112-
const key = button.getAttribute('data-copy-key') || '';
141+
copyButton.addEventListener('click', async function() {
142+
const key = copyButton.getAttribute('data-copy-key') || '';
113143
if (!key) {
114144
return;
115145
}
116146
await navigator.clipboard.writeText(key);
117-
const prev = button.textContent;
118-
button.textContent = 'Copied';
147+
const prev = copyButton.textContent;
148+
copyButton.textContent = 'Copied';
119149
setTimeout(function() {
120-
button.textContent = prev;
150+
copyButton.textContent = prev;
121151
}, 1200);
122152
});
123-
});
124153

125-
workspaceList.querySelectorAll('button[data-toggle-key]').forEach(function(button) {
126-
button.addEventListener('click', function() {
127-
const visible = button.getAttribute('data-visible') === 'true';
128-
const code = button.parentElement.querySelector('[data-key-view]');
129-
if (!code) {
154+
toggleButton.addEventListener('click', function() {
155+
const visible = toggleButton.getAttribute('data-visible') === 'true';
156+
if (!keyView) {
130157
return;
131158
}
132159
if (visible) {
133-
code.textContent = button.getAttribute('data-masked') || '';
134-
button.setAttribute('data-visible', 'false');
135-
button.setAttribute('aria-label', 'Show API key');
160+
keyView.textContent = toggleButton.getAttribute('data-masked') || '';
161+
toggleButton.setAttribute('data-visible', 'false');
162+
toggleButton.setAttribute('aria-label', 'Show API key');
136163
} else {
137-
code.textContent = button.getAttribute('data-key') || '';
138-
button.setAttribute('data-visible', 'true');
139-
button.setAttribute('aria-label', 'Hide API key');
164+
keyView.textContent = toggleButton.getAttribute('data-key') || '';
165+
toggleButton.setAttribute('data-visible', 'true');
166+
toggleButton.setAttribute('aria-label', 'Hide API key');
140167
}
141168
});
142-
});
169+
170+
if (editButton) {
171+
editButton.addEventListener('click', async function() {
172+
const section = editButton.closest('section');
173+
if (!section) {
174+
return;
175+
}
176+
const workspaceId = section.getAttribute('data-workspace-id');
177+
const nameEl = section.querySelector('[data-workspace-name]');
178+
if (!workspaceId || !nameEl) {
179+
return;
180+
}
181+
const currentName = nameEl.textContent || '';
182+
const nextName = window.prompt('Update workspace name', currentName);
183+
if (nextName == null || nextName.trim() === '' || nextName.trim() === currentName) {
184+
return;
185+
}
186+
187+
try {
188+
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
189+
const { workspace: updatedWorkspace } = await postJson('/.netlify/functions/updateWorkspace', {
190+
workspaceId: workspaceId.trim(),
191+
name: nextName.trim()
192+
}, token);
193+
nameEl.textContent = updatedWorkspace.name || nextName.trim();
194+
} catch (err) {
195+
showError(err.message || 'Failed to update workspace name');
196+
}
197+
});
198+
}
199+
}
200+
}
201+
202+
function createElementFromTemplate(templateId) {
203+
const template = document.getElementById(templateId);
204+
if (!template) {
205+
throw new Error('Missing template: ' + templateId);
206+
}
207+
const container = document.createElement('div');
208+
container.innerHTML = template.innerHTML.trim();
209+
const element = container.firstElementChild;
210+
if (!element) {
211+
throw new Error('Template produced no element: ' + templateId);
212+
}
213+
return element;
143214
}
144215

145216
async function postJson(url, body, token) {
@@ -174,15 +245,6 @@ <h1 class="text-3xl font-bold tracking-tight">My Account</h1>
174245
function showError(message) {
175246
errorEl.textContent = message;
176247
}
177-
178-
function escapeHtml(str) {
179-
return String(str)
180-
.replaceAll('&', '&amp;')
181-
.replaceAll('<', '&lt;')
182-
.replaceAll('>', '&gt;')
183-
.replaceAll('"', '&quot;')
184-
.replaceAll("'", '&#39;');
185-
}
186248
})();
187249
</script>
188250
</body>

src/actions/stripeWebhook.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ module.exports = async function stripeWebhook(params, event) {
7878
setupUrl.searchParams.set('workspaceId', newWorkspace._id.toString());
7979
const $ = cheerio.load(newWorkspaceTemplate);
8080
$('#workspace-name').text(newWorkspace.name);
81-
$('#setup-link').attr('href', setupUrl.toString()).text(setupUrl.toString());
81+
$('#setup-link').attr('href', setupUrl.toString());
82+
$('#setup-link-fallback').attr('href', setupUrl.toString()).text(setupUrl.toString());
8283
await mailgun.sendEmail({
8384
to: customerEmail,
8485
from: process.env.MAILGUN_FROM_EMAIL,
@@ -104,7 +105,8 @@ module.exports = async function stripeWebhook(params, event) {
104105
setupUrl.searchParams.set('workspaceId', workspace._id.toString());
105106
const $ = cheerio.load(newWorkspaceTemplate);
106107
$('#workspace-name').text(workspace.name || randomWorkspaceName);
107-
$('#setup-link').attr('href', setupUrl.toString()).text(setupUrl.toString());
108+
$('#setup-link').attr('href', setupUrl.toString());
109+
$('#setup-link-fallback').attr('href', setupUrl.toString()).text(setupUrl.toString());
108110
await mailgun.sendEmail({
109111
to: customerEmail,
110112
from: process.env.MAILGUN_FROM_EMAIL,

src/actions/updateWorkspace.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict';
2+
3+
const Archetype = require('archetype');
4+
const connect = require('../../src/db');
5+
const mongoose = require('mongoose');
6+
7+
const UpdateWorkspaceParams = new Archetype({
8+
authorization: {
9+
$type: 'string',
10+
$required: true
11+
},
12+
workspaceId: {
13+
$type: mongoose.Types.ObjectId,
14+
$required: true
15+
},
16+
name: {
17+
$type: 'string'
18+
}
19+
}).compile('UpdateWorkspaceParams');
20+
21+
module.exports = async function updateWorkspace(params) {
22+
const { authorization, workspaceId, name } = new UpdateWorkspaceParams(params);
23+
24+
const db = await connect();
25+
const { AccessToken, Workspace } = db.models;
26+
27+
const accessToken = await AccessToken.findById(authorization).orFail(new Error('Invalid or expired access token'));
28+
if (accessToken.expiresAt < new Date()) {
29+
throw new Error('Access token has expired');
30+
}
31+
32+
const workspace = await Workspace.findById(workspaceId).orFail(new Error('Workspace not found'));
33+
const roles = workspace.members.find(member => member.userId.toString() === accessToken.userId.toString())?.roles || [];
34+
if (!roles.includes('owner') && !roles.includes('admin')) {
35+
throw new Error('Forbidden');
36+
}
37+
38+
if (name != null) {
39+
const trimmed = name.trim();
40+
if (!trimmed) {
41+
throw new Error('Workspace name is required');
42+
}
43+
if (trimmed.length > 120) {
44+
throw new Error('Workspace name is too long');
45+
}
46+
workspace.name = trimmed;
47+
}
48+
49+
await workspace.save();
50+
51+
return { workspace };
52+
};

src/emailTemplates/newWorkspace.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:24px;">
1313
<tr>
1414
<td>
15-
<h1 style="margin:0 0 12px 0;font-size:24px;line-height:1.25;">Welcome to Mongoose Studio</h1>
16-
<p style="margin:0 0 12px 0;font-size:14px;color:#334155;">Your new workspace is ready.</p>
15+
<h1 style="margin:0 0 12px 0;font-size:24px;line-height:1.25;color:#880000;">Welcome to Mongoose Studio</h1>
16+
<p style="margin:0 0 12px 0;font-size:14px;color:#334155;">Thank you for your support. Your new workspace is ready and we appreciate you being here.</p>
1717
<p style="margin:0 0 16px 0;font-size:14px;"><strong>Workspace:</strong> <span id="workspace-name"></span></p>
1818
<p style="margin:0 0 16px 0;font-size:14px;color:#334155;">Use the link below to sign in and finish setup:</p>
19-
<p style="margin:0 0 20px 0;"><a id="setup-link" href="#" style="display:inline-block;background:#0f766e;color:#ffffff;text-decoration:none;padding:10px 14px;border-radius:8px;font-size:14px;">Set up my account</a></p>
19+
<p style="margin:0 0 8px 0;"><a id="setup-link" href="#" style="display:inline-block;background:#880000;color:#ffffff;text-decoration:none;padding:10px 14px;border-radius:8px;font-size:14px;">Finish Setup</a></p>
20+
<p style="margin:0 0 20px 0;font-size:12px;color:#64748b;line-height:1.5;">If the button does not work, copy and paste this link into your browser:<br><a id="setup-link-fallback" href="#" style="color:#880000;word-break:break-all;"></a></p>
21+
<p style="margin:0;font-size:14px;color:#334155;">Need help? We're here for you on Discord: <a href="https://discord.gg/P3YCfKYxpy" style="color:#880000;font-weight:600;">Get Help on Discord</a></p>
2022
</td>
2123
</tr>
2224
</table>

0 commit comments

Comments
 (0)