skopix 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Skopix · Accept invite</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@500;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #080810; --surface: #0d0d1a; --surface2: #12121f;
13
+ --border: rgba(255,255,255,0.06);
14
+ --cyan: #00d4ff; --cyan-dim: rgba(0,212,255,0.12);
15
+ --red: #ef4444; --green: #10b981; --yellow: #f59e0b;
16
+ --text: #e8eaf0; --muted: #5a6180; --muted2: #3a3f5c;
17
+ --white: #ffffff;
18
+ --mono: 'DM Mono', monospace; --display: 'Syne', sans-serif;
19
+ }
20
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body {
22
+ background: var(--bg); color: var(--text); font-family: var(--display);
23
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
24
+ padding: 24px; position: relative; overflow-x: hidden;
25
+ }
26
+ body::before {
27
+ content: ''; position: fixed; inset: 0;
28
+ background-image:
29
+ linear-gradient(rgba(0,212,255,0.03) 1px, transparent 1px),
30
+ linear-gradient(90deg, rgba(0,212,255,0.03) 1px, transparent 1px);
31
+ background-size: 60px 60px; pointer-events: none; z-index: 0;
32
+ }
33
+ body::after {
34
+ content: ''; position: fixed; inset: 0;
35
+ background: radial-gradient(circle at 50% 30%, rgba(0,212,255,0.08) 0%, transparent 60%);
36
+ pointer-events: none; z-index: 0;
37
+ }
38
+ .wrap { position: relative; z-index: 1; max-width: 480px; width: 100%; }
39
+ .brand {
40
+ font-family: var(--mono); font-size: 13px; font-weight: 500;
41
+ color: var(--cyan); letter-spacing: 0.2em; text-align: center; margin-bottom: 12px;
42
+ }
43
+ .title {
44
+ font-family: var(--display); font-weight: 700; font-size: 36px;
45
+ color: var(--white); text-align: center; margin-bottom: 12px; line-height: 1.1;
46
+ }
47
+ .sub {
48
+ font-family: var(--mono); font-size: 13px; color: var(--muted);
49
+ text-align: center; margin-bottom: 36px; line-height: 1.6;
50
+ }
51
+ .card {
52
+ background: var(--surface); border: 1px solid var(--border);
53
+ border-radius: 16px; padding: 32px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
54
+ }
55
+ .context-pill {
56
+ display: inline-flex; gap: 6px; align-items: center;
57
+ padding: 4px 10px; border-radius: 999px;
58
+ background: var(--cyan-dim); border: 1px solid rgba(0,212,255,0.3);
59
+ font-family: var(--mono); font-size: 11px; color: var(--cyan);
60
+ letter-spacing: 0.05em; text-transform: uppercase;
61
+ }
62
+ .context-row {
63
+ display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; justify-content: center;
64
+ }
65
+ .field { margin-bottom: 18px; }
66
+ .label {
67
+ display: block; font-family: var(--mono); font-size: 11px;
68
+ letter-spacing: 0.1em; color: var(--muted); text-transform: uppercase; margin-bottom: 8px;
69
+ }
70
+ .input {
71
+ width: 100%; padding: 12px 14px;
72
+ background: var(--surface2); border: 1px solid var(--border);
73
+ border-radius: 8px; color: var(--white); font-family: var(--mono);
74
+ font-size: 14px; outline: none; transition: border-color 0.2s;
75
+ }
76
+ .input:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-dim); }
77
+ .input:disabled { opacity: 0.6; cursor: not-allowed; }
78
+ .help { font-family: var(--mono); font-size: 11px; color: var(--muted2); margin-top: 6px; }
79
+ .btn {
80
+ width: 100%; padding: 14px 24px;
81
+ background: var(--cyan); color: var(--bg); border: none;
82
+ border-radius: 8px; font-family: var(--mono); font-size: 13px;
83
+ font-weight: 500; letter-spacing: 0.05em; cursor: pointer;
84
+ transition: all 0.2s; margin-top: 8px; text-transform: uppercase;
85
+ }
86
+ .btn:hover { background: #00b8e0; box-shadow: 0 0 30px rgba(0,212,255,0.3); }
87
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
88
+ .alert {
89
+ padding: 12px 14px; border-radius: 8px;
90
+ font-family: var(--mono); font-size: 12px;
91
+ margin-bottom: 16px; line-height: 1.5;
92
+ }
93
+ .alert.error {
94
+ background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: var(--red);
95
+ }
96
+ .alert.success {
97
+ background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); color: var(--green);
98
+ }
99
+ .note {
100
+ font-family: var(--mono); font-size: 11px; color: var(--muted);
101
+ text-align: center; margin-top: 24px; line-height: 1.6;
102
+ }
103
+ .hidden { display: none; }
104
+ .loading {
105
+ text-align: center; padding: 40px;
106
+ font-family: var(--mono); font-size: 13px; color: var(--muted);
107
+ }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <div class="wrap">
112
+ <div class="brand">SKOPIX · INVITE</div>
113
+ <h1 class="title">You're invited.</h1>
114
+ <p class="sub" id="sub">Loading your invitation...</p>
115
+
116
+ <div id="loading-card" class="card loading">Verifying invite token...</div>
117
+
118
+ <div id="form-card" class="card hidden">
119
+ <div class="context-row">
120
+ <div class="context-pill" id="role-pill">ROLE</div>
121
+ <div class="context-pill" id="email-pill" style="background:rgba(255,255,255,0.04);border-color:var(--border);color:var(--text)">EMAIL</div>
122
+ </div>
123
+
124
+ <div id="alert" class="alert hidden"></div>
125
+
126
+ <form id="accept-form">
127
+ <div class="field">
128
+ <label class="label" for="name">Your full name</label>
129
+ <input class="input" id="name" name="name" type="text" autocomplete="name" required minlength="1" placeholder="Bob Smith">
130
+ </div>
131
+
132
+ <div class="field">
133
+ <label class="label" for="password">Choose a password</label>
134
+ <input class="input" id="password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="At least 8 characters">
135
+ <div class="help">Make it strong — at least 8 characters. You'll use this to sign in.</div>
136
+ </div>
137
+
138
+ <div class="field">
139
+ <label class="label" for="password2">Confirm password</label>
140
+ <input class="input" id="password2" name="password2" type="password" autocomplete="new-password" required minlength="8" placeholder="Type it again">
141
+ </div>
142
+
143
+ <button id="submit-btn" class="btn" type="submit">Accept invite &amp; sign in →</button>
144
+ </form>
145
+ </div>
146
+
147
+ <div id="error-card" class="card hidden">
148
+ <div class="alert error" id="error-message" style="margin-bottom:0">This invite is no longer valid.</div>
149
+ </div>
150
+
151
+ <p class="note" id="footer-note">Already have an account? <a href="/login" style="color:var(--cyan);text-decoration:none">Sign in</a></p>
152
+ </div>
153
+
154
+ <script>
155
+ // Extract token from URL path: /invite/<token>
156
+ const token = window.location.pathname.replace(/^\/invite\//, '').replace(/\/$/, '');
157
+
158
+ const loadingCard = document.getElementById('loading-card');
159
+ const formCard = document.getElementById('form-card');
160
+ const errorCard = document.getElementById('error-card');
161
+ const errorMsg = document.getElementById('error-message');
162
+ const sub = document.getElementById('sub');
163
+ const alertEl = document.getElementById('alert');
164
+ const submitBtn = document.getElementById('submit-btn');
165
+
166
+ function showAlert(message, kind = 'error') {
167
+ alertEl.textContent = message;
168
+ alertEl.className = 'alert ' + kind;
169
+ alertEl.classList.remove('hidden');
170
+ }
171
+
172
+ async function loadInvite() {
173
+ if (!token || token.length < 8) {
174
+ loadingCard.classList.add('hidden');
175
+ errorCard.classList.remove('hidden');
176
+ errorMsg.textContent = 'No invite token provided.';
177
+ return;
178
+ }
179
+ try {
180
+ const res = await fetch('/api/invites/' + encodeURIComponent(token));
181
+ const data = await res.json();
182
+ if (!res.ok) {
183
+ loadingCard.classList.add('hidden');
184
+ errorCard.classList.remove('hidden');
185
+ errorMsg.textContent = data.error || 'Invalid or expired invite.';
186
+ return;
187
+ }
188
+ loadingCard.classList.add('hidden');
189
+ formCard.classList.remove('hidden');
190
+ sub.textContent = `${data.invitedByName} has invited you to join their Skopix team.`;
191
+ document.getElementById('role-pill').textContent = data.role.toUpperCase();
192
+ document.getElementById('email-pill').textContent = data.email;
193
+ } catch (err) {
194
+ loadingCard.classList.add('hidden');
195
+ errorCard.classList.remove('hidden');
196
+ errorMsg.textContent = 'Network error: ' + err.message;
197
+ }
198
+ }
199
+ loadInvite();
200
+
201
+ document.getElementById('accept-form').addEventListener('submit', async (e) => {
202
+ e.preventDefault();
203
+ alertEl.classList.add('hidden');
204
+
205
+ const name = document.getElementById('name').value.trim();
206
+ const password = document.getElementById('password').value;
207
+ const password2 = document.getElementById('password2').value;
208
+
209
+ if (password !== password2) {
210
+ showAlert("Passwords don't match.");
211
+ return;
212
+ }
213
+ if (password.length < 8) {
214
+ showAlert('Password must be at least 8 characters.');
215
+ return;
216
+ }
217
+
218
+ submitBtn.disabled = true;
219
+ submitBtn.textContent = 'Creating account...';
220
+
221
+ try {
222
+ const res = await fetch('/api/invites/' + encodeURIComponent(token) + '/accept', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ name, password }),
226
+ });
227
+ const data = await res.json();
228
+ if (!res.ok) {
229
+ showAlert(data.error || 'Failed to accept invite.');
230
+ submitBtn.disabled = false;
231
+ submitBtn.textContent = 'Accept invite & sign in →';
232
+ return;
233
+ }
234
+ showAlert('Welcome to Skopix. Redirecting...', 'success');
235
+ setTimeout(() => { window.location.href = '/app/'; }, 800);
236
+ } catch (err) {
237
+ showAlert('Network error: ' + err.message);
238
+ submitBtn.disabled = false;
239
+ submitBtn.textContent = 'Accept invite & sign in →';
240
+ }
241
+ });
242
+ </script>
243
+ </body>
244
+ </html>
package/web/login.html ADDED
@@ -0,0 +1,271 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Skopix · Sign in</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@500;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #080810;
13
+ --surface: #0d0d1a;
14
+ --surface2: #12121f;
15
+ --border: rgba(255,255,255,0.06);
16
+ --border-bright: rgba(0,212,255,0.2);
17
+ --cyan: #00d4ff;
18
+ --cyan-dim: rgba(0,212,255,0.12);
19
+ --red: #ef4444;
20
+ --green: #10b981;
21
+ --yellow: #f59e0b;
22
+ --text: #e8eaf0;
23
+ --muted: #5a6180;
24
+ --muted2: #3a3f5c;
25
+ --white: #ffffff;
26
+ --mono: 'DM Mono', monospace;
27
+ --display: 'Syne', sans-serif;
28
+ }
29
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
30
+ body {
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: var(--display);
34
+ min-height: 100vh;
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ padding: 24px;
39
+ position: relative;
40
+ overflow-x: hidden;
41
+ }
42
+ body::before {
43
+ content: '';
44
+ position: fixed;
45
+ inset: 0;
46
+ background-image:
47
+ linear-gradient(rgba(0,212,255,0.03) 1px, transparent 1px),
48
+ linear-gradient(90deg, rgba(0,212,255,0.03) 1px, transparent 1px);
49
+ background-size: 60px 60px;
50
+ pointer-events: none;
51
+ z-index: 0;
52
+ }
53
+ body::after {
54
+ content: '';
55
+ position: fixed;
56
+ inset: 0;
57
+ background: radial-gradient(circle at 50% 30%, rgba(0,212,255,0.08) 0%, transparent 60%);
58
+ pointer-events: none;
59
+ z-index: 0;
60
+ }
61
+ .wrap {
62
+ position: relative;
63
+ z-index: 1;
64
+ max-width: 440px;
65
+ width: 100%;
66
+ }
67
+ .brand {
68
+ font-family: var(--mono);
69
+ font-size: 13px;
70
+ font-weight: 500;
71
+ color: var(--cyan);
72
+ letter-spacing: 0.2em;
73
+ text-align: center;
74
+ margin-bottom: 12px;
75
+ }
76
+ .title {
77
+ font-family: var(--display);
78
+ font-weight: 700;
79
+ font-size: 36px;
80
+ color: var(--white);
81
+ text-align: center;
82
+ margin-bottom: 12px;
83
+ line-height: 1.1;
84
+ }
85
+ .sub {
86
+ font-family: var(--mono);
87
+ font-size: 13px;
88
+ color: var(--muted);
89
+ text-align: center;
90
+ margin-bottom: 36px;
91
+ line-height: 1.6;
92
+ }
93
+ .card {
94
+ background: var(--surface);
95
+ border: 1px solid var(--border);
96
+ border-radius: 16px;
97
+ padding: 32px;
98
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
99
+ }
100
+ .field { margin-bottom: 18px; }
101
+ .label {
102
+ display: block;
103
+ font-family: var(--mono);
104
+ font-size: 11px;
105
+ letter-spacing: 0.1em;
106
+ color: var(--muted);
107
+ text-transform: uppercase;
108
+ margin-bottom: 8px;
109
+ }
110
+ .input {
111
+ width: 100%;
112
+ padding: 12px 14px;
113
+ background: var(--surface2);
114
+ border: 1px solid var(--border);
115
+ border-radius: 8px;
116
+ color: var(--white);
117
+ font-family: var(--mono);
118
+ font-size: 14px;
119
+ outline: none;
120
+ transition: border-color 0.2s;
121
+ }
122
+ .input:focus {
123
+ border-color: var(--cyan);
124
+ box-shadow: 0 0 0 3px var(--cyan-dim);
125
+ }
126
+ .btn {
127
+ width: 100%;
128
+ padding: 14px 24px;
129
+ background: var(--cyan);
130
+ color: var(--bg);
131
+ border: none;
132
+ border-radius: 8px;
133
+ font-family: var(--mono);
134
+ font-size: 13px;
135
+ font-weight: 500;
136
+ letter-spacing: 0.05em;
137
+ cursor: pointer;
138
+ transition: all 0.2s;
139
+ margin-top: 8px;
140
+ text-transform: uppercase;
141
+ }
142
+ .btn:hover { background: #00b8e0; box-shadow: 0 0 30px rgba(0,212,255,0.3); }
143
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
144
+ .alert {
145
+ padding: 12px 14px;
146
+ border-radius: 8px;
147
+ font-family: var(--mono);
148
+ font-size: 12px;
149
+ margin-bottom: 16px;
150
+ line-height: 1.5;
151
+ }
152
+ .alert.error {
153
+ background: rgba(239,68,68,0.1);
154
+ border: 1px solid rgba(239,68,68,0.3);
155
+ color: var(--red);
156
+ }
157
+ .alert.success {
158
+ background: rgba(16,185,129,0.1);
159
+ border: 1px solid rgba(16,185,129,0.3);
160
+ color: var(--green);
161
+ }
162
+ .note {
163
+ font-family: var(--mono);
164
+ font-size: 11px;
165
+ color: var(--muted);
166
+ text-align: center;
167
+ margin-top: 24px;
168
+ line-height: 1.6;
169
+ }
170
+ .hidden { display: none; }
171
+ </style>
172
+ </head>
173
+ <body>
174
+ <div class="wrap">
175
+ <div class="brand">SKOPIX</div>
176
+ <h1 class="title">Sign in.</h1>
177
+ <p class="sub">Welcome back. Enter your email and password.</p>
178
+
179
+ <div class="card">
180
+ <div id="alert" class="alert hidden"></div>
181
+
182
+ <form id="login-form">
183
+ <div class="field">
184
+ <label class="label" for="email">Email address</label>
185
+ <input class="input" id="email" name="email" type="email" autocomplete="email" required autofocus placeholder="you@yourcompany.com">
186
+ </div>
187
+
188
+ <div class="field">
189
+ <label class="label" for="password">Password</label>
190
+ <input class="input" id="password" name="password" type="password" autocomplete="current-password" required placeholder="Your password">
191
+ </div>
192
+
193
+ <button id="submit-btn" class="btn" type="submit">Sign in →</button>
194
+ </form>
195
+ </div>
196
+
197
+ <p class="note">
198
+ Need an account? Ask your Skopix admin for an invite.
199
+ </p>
200
+ </div>
201
+
202
+ <script>
203
+ const form = document.getElementById('login-form');
204
+ const alertEl = document.getElementById('alert');
205
+ const submitBtn = document.getElementById('submit-btn');
206
+
207
+ function showAlert(message, kind = 'error') {
208
+ alertEl.textContent = message;
209
+ alertEl.className = 'alert ' + kind;
210
+ alertEl.classList.remove('hidden');
211
+ }
212
+
213
+ // On load: if already signed in, redirect to dashboard. If team mode is off,
214
+ // no point being here either.
215
+ async function checkStatus() {
216
+ try {
217
+ const statusRes = await fetch('/api/team/status');
218
+ if (!statusRes.ok) {
219
+ window.location.href = '/app/';
220
+ return;
221
+ }
222
+ const status = await statusRes.json();
223
+ if (status.needsSetup) {
224
+ window.location.href = '/setup';
225
+ return;
226
+ }
227
+ // Try /api/auth/me - if logged in, skip to dashboard
228
+ const meRes = await fetch('/api/auth/me');
229
+ if (meRes.ok) {
230
+ window.location.href = '/app/';
231
+ }
232
+ } catch {}
233
+ }
234
+ checkStatus();
235
+
236
+ form.addEventListener('submit', async (e) => {
237
+ e.preventDefault();
238
+ alertEl.classList.add('hidden');
239
+
240
+ const email = document.getElementById('email').value.trim();
241
+ const password = document.getElementById('password').value;
242
+
243
+ submitBtn.disabled = true;
244
+ submitBtn.textContent = 'Signing in...';
245
+
246
+ try {
247
+ const res = await fetch('/api/auth/login', {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json' },
250
+ body: JSON.stringify({ email, password }),
251
+ });
252
+ const data = await res.json();
253
+
254
+ if (!res.ok) {
255
+ showAlert(data.error || 'Sign in failed.');
256
+ submitBtn.disabled = false;
257
+ submitBtn.textContent = 'Sign in →';
258
+ return;
259
+ }
260
+
261
+ showAlert('Signed in. Redirecting...', 'success');
262
+ setTimeout(() => { window.location.href = '/app/'; }, 600);
263
+ } catch (err) {
264
+ showAlert('Network error: ' + err.message);
265
+ submitBtn.disabled = false;
266
+ submitBtn.textContent = 'Sign in →';
267
+ }
268
+ });
269
+ </script>
270
+ </body>
271
+ </html>