kalvium-worklog 1.1.0 → 1.2.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.
- package/README.md +11 -36
- package/dist/cli.js +2 -17
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/postinstall.js +0 -1
- package/dist/submit.d.ts +0 -2
- package/dist/submit.js +6 -14
- package/package.json +2 -3
- package/templates/worklog_standalone.html +0 -423
- package/templates/worklog_template.html +0 -387
package/README.md
CHANGED
|
@@ -11,66 +11,40 @@ npm install -g kalvium-worklog
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
# Full setup (login + discover position + schedule
|
|
14
|
+
# Full setup (login + discover position + schedule)
|
|
15
15
|
kalvium-worklog install
|
|
16
16
|
|
|
17
17
|
# Or step by step:
|
|
18
18
|
kalvium-worklog login # log in via browser (Google + 2FA)
|
|
19
19
|
kalvium-worklog discover # auto-discover your position ID
|
|
20
20
|
kalvium-worklog schedule 14:00 # daily auto-submit at 2 PM
|
|
21
|
-
kalvium-worklog webapp # generate phone web app
|
|
22
21
|
```
|
|
23
22
|
|
|
24
23
|
## Commands
|
|
25
24
|
|
|
26
25
|
| Command | Description |
|
|
27
26
|
|---------|-------------|
|
|
28
|
-
| `kalvium-worklog install` | Full setup: login + discover + schedule
|
|
27
|
+
| `kalvium-worklog install` | Full setup: login + discover + schedule |
|
|
29
28
|
| `kalvium-worklog login` | Log in to Kalvium (opens browser, waits for Google + 2FA) |
|
|
30
29
|
| `kalvium-worklog discover` | Auto-discover your internship position ID |
|
|
31
|
-
| `kalvium-worklog submit [text]` | Submit worklog (default: "
|
|
30
|
+
| `kalvium-worklog submit [text]` | Submit worklog (default: "Working on assigned task") |
|
|
32
31
|
| `kalvium-worklog submit "built a feature"` | Submit with custom text |
|
|
33
|
-
| `kalvium-worklog submit --status remote` | Submit with custom status |
|
|
34
32
|
| `kalvium-worklog submit --dry-run` | Test without submitting |
|
|
35
33
|
| `kalvium-worklog status` | Check recent worklog entries |
|
|
36
34
|
| `kalvium-worklog schedule 14:00` | Set up daily auto-submit at 2 PM |
|
|
37
35
|
| `kalvium-worklog schedule --remove` | Remove daily auto-submit |
|
|
38
|
-
| `kalvium-worklog webapp [path]` | Generate phone web app HTML |
|
|
39
36
|
| `kalvium-worklog daily` | Run daily submit (used by scheduler) |
|
|
37
|
+
| `kalvium-worklog catchup` | Check if today's worklog is missing and submit if needed |
|
|
40
38
|
|
|
41
39
|
## Features
|
|
42
40
|
|
|
43
41
|
- **Cross-platform**: macOS (launchd), Windows (Task Scheduler), Linux (cron)
|
|
44
42
|
- **Auto re-login**: If the token expires, automatically opens browser, waits for login, and retries
|
|
45
43
|
- **Token auto-refresh**: Refresh token rotates on each use, lasts 6 days
|
|
46
|
-
- **Phone web app**: Submit from your iPhone/Android (no computer needed after setup)
|
|
47
44
|
- **Auto-discovers position ID**: Works for any user, no hardcoded IDs
|
|
48
45
|
- **Weekend skipping**: Auto-submit skips Saturdays and Sundays
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
| Key | Label |
|
|
53
|
-
|-----|-------|
|
|
54
|
-
| `on_site` | Working on-site (Company location) |
|
|
55
|
-
| `remote` | Working Remotely (Not in the Kalvium environment) |
|
|
56
|
-
| `classroom` | Working out of the Kalvium environment (Classroom) |
|
|
57
|
-
| `holiday` | Today was a company Holiday |
|
|
58
|
-
| `leave` | Took an approved leave from work |
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
kalvium-worklog submit "worked from home" --status remote
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## Phone Web App
|
|
65
|
-
|
|
66
|
-
After running `kalvium-worklog webapp`:
|
|
67
|
-
|
|
68
|
-
1. AirDrop `~/KalviumWorklog.html` to your phone
|
|
69
|
-
2. Open in **Safari** (iPhone) or **Chrome** (Android)
|
|
70
|
-
3. **Add to Home Screen**
|
|
71
|
-
4. Tap the icon to submit worklogs from your phone
|
|
72
|
-
|
|
73
|
-
The phone app works independently — it refreshes its own token and auto-retries if the token expires.
|
|
46
|
+
- **Network retry**: Retries up to 5 times on network failure (covers laptop waking up, internet reconnecting)
|
|
47
|
+
- **Catch-up on wake**: If the scheduled time was missed (laptop asleep/offline), submits when the device wakes up
|
|
74
48
|
|
|
75
49
|
## How It Works
|
|
76
50
|
|
|
@@ -81,9 +55,10 @@ One-time setup:
|
|
|
81
55
|
Daily auto-submit (scheduler):
|
|
82
56
|
Refresh token → PUT worklog API → done
|
|
83
57
|
If token expired → auto open browser → re-login → retry
|
|
58
|
+
If network fails → retry up to 5 times (1 min apart)
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
60
|
+
Catch-up (on login/wake, every 30 min):
|
|
61
|
+
Check if today's worklog is submitted → if not, submit it
|
|
87
62
|
```
|
|
88
63
|
|
|
89
64
|
## Token Lifecycle
|
|
@@ -100,10 +75,10 @@ Manual submit:
|
|
|
100
75
|
├── refresh_token.json # Current token (auto-rotates)
|
|
101
76
|
├── config.json # Position ID + API URL
|
|
102
77
|
├── daily_submit.js # Scheduler entry point
|
|
78
|
+
├── catchup_submit.js # Catch-up entry point
|
|
103
79
|
└── worklog.log # Log file
|
|
104
80
|
|
|
105
81
|
~/.kalvium_profile/ # Browser profile (saved login)
|
|
106
|
-
~/KalviumWorklog.html # Phone web app
|
|
107
82
|
```
|
|
108
83
|
|
|
109
84
|
## Uninstall
|
|
@@ -111,7 +86,7 @@ Manual submit:
|
|
|
111
86
|
```bash
|
|
112
87
|
kalvium-worklog schedule --remove
|
|
113
88
|
npm uninstall -g kalvium-worklog
|
|
114
|
-
rm -rf ~/.kalvium ~/.kalvium_profile
|
|
89
|
+
rm -rf ~/.kalvium ~/.kalvium_profile
|
|
115
90
|
```
|
|
116
91
|
|
|
117
92
|
## Requirements
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,6 @@ import { captureToken } from "./capture-token.js";
|
|
|
4
4
|
import { discoverPosition } from "./discover-position.js";
|
|
5
5
|
import { submitWorklog, checkStatus, dailySubmit, catchUpSubmit } from "./submit.js";
|
|
6
6
|
import { setupScheduler, removeScheduler } from "./scheduler.js";
|
|
7
|
-
import { generateWebapp } from "./generate-webapp.js";
|
|
8
7
|
import { loadToken, saveConfig, getPositionIdFromToken } from "./config.js";
|
|
9
8
|
import { existsSync } from "fs";
|
|
10
9
|
import { join, dirname } from "path";
|
|
@@ -42,7 +41,6 @@ program
|
|
|
42
41
|
program
|
|
43
42
|
.command("submit [text]")
|
|
44
43
|
.description("Submit worklog (default text: 'Working on assigned task')")
|
|
45
|
-
.option("--status <type>", "Work status", "on_site")
|
|
46
44
|
.option("--dry-run", "Test without submitting")
|
|
47
45
|
.action(async (text, opts) => {
|
|
48
46
|
// Skip weekends (unless dry run)
|
|
@@ -53,7 +51,6 @@ program
|
|
|
53
51
|
}
|
|
54
52
|
const result = await submitWorklog({
|
|
55
53
|
text: text ?? "Working on assigned task",
|
|
56
|
-
status: opts.status,
|
|
57
54
|
dryRun: opts.dryRun,
|
|
58
55
|
});
|
|
59
56
|
if (!result.success)
|
|
@@ -135,19 +132,10 @@ program
|
|
|
135
132
|
if (!ok)
|
|
136
133
|
process.exit(1);
|
|
137
134
|
});
|
|
138
|
-
// ─── webapp ────────────────────────────────────────────────────────────────
|
|
139
|
-
program
|
|
140
|
-
.command("webapp [path]")
|
|
141
|
-
.description("Generate phone web app HTML (default: ~/KalviumWorklog.html)")
|
|
142
|
-
.action(async (path) => {
|
|
143
|
-
const ok = await generateWebapp(path);
|
|
144
|
-
if (!ok)
|
|
145
|
-
process.exit(1);
|
|
146
|
-
});
|
|
147
135
|
// ─── install (full setup) ──────────────────────────────────────────────────
|
|
148
136
|
program
|
|
149
137
|
.command("install")
|
|
150
|
-
.description("Full setup: login + discover + schedule
|
|
138
|
+
.description("Full setup: login + discover + schedule")
|
|
151
139
|
.action(async () => {
|
|
152
140
|
const readline = await import("readline");
|
|
153
141
|
const rl = readline.createInterface({
|
|
@@ -205,9 +193,6 @@ program
|
|
|
205
193
|
}
|
|
206
194
|
}
|
|
207
195
|
await setupScheduler({ hour, minute });
|
|
208
|
-
// Step 4: Webapp
|
|
209
|
-
console.log("\n── Step 4: Generate phone web app ──");
|
|
210
|
-
await generateWebapp();
|
|
211
196
|
console.log("\n✅ Setup complete!\n");
|
|
212
197
|
console.log("Commands:");
|
|
213
198
|
console.log(" kalvium-worklog submit # submit with default text");
|
|
@@ -230,7 +215,7 @@ if (process.argv.length <= 2) {
|
|
|
230
215
|
console.log(" kalvium-worklog install");
|
|
231
216
|
console.log("");
|
|
232
217
|
console.log(" This will log you in, discover your position ID,");
|
|
233
|
-
console.log(" set up daily auto-submit
|
|
218
|
+
console.log(" and set up daily auto-submit.");
|
|
234
219
|
console.log("");
|
|
235
220
|
console.log(" Or run kalvium-worklog --help to see all commands.");
|
|
236
221
|
console.log("");
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export { captureToken } from "./capture-token.js";
|
|
2
2
|
export { discoverPosition } from "./discover-position.js";
|
|
3
|
-
export { submitWorklog, checkStatus, dailySubmit, catchUpSubmit
|
|
3
|
+
export { submitWorklog, checkStatus, dailySubmit, catchUpSubmit } from "./submit.js";
|
|
4
4
|
export type { SubmitOptions, SubmitResult } from "./submit.js";
|
|
5
5
|
export { setupScheduler, removeScheduler } from "./scheduler.js";
|
|
6
6
|
export type { ScheduleOptions } from "./scheduler.js";
|
|
7
|
-
export { generateWebapp } from "./generate-webapp.js";
|
|
8
7
|
export { refreshToken } from "./refresh.js";
|
|
9
8
|
export { loadToken, saveToken, loadConfig, saveConfig, getWorklogApi, INSTALL_DIR, TOKEN_FILE, CONFIG_FILE, LOG_FILE, } from "./config.js";
|
|
10
9
|
export type { TokenData, ConfigData } from "./config.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export { captureToken } from "./capture-token.js";
|
|
2
2
|
export { discoverPosition } from "./discover-position.js";
|
|
3
|
-
export { submitWorklog, checkStatus, dailySubmit, catchUpSubmit
|
|
3
|
+
export { submitWorklog, checkStatus, dailySubmit, catchUpSubmit } from "./submit.js";
|
|
4
4
|
export { setupScheduler, removeScheduler } from "./scheduler.js";
|
|
5
|
-
export { generateWebapp } from "./generate-webapp.js";
|
|
6
5
|
export { refreshToken } from "./refresh.js";
|
|
7
6
|
export { loadToken, saveToken, loadConfig, saveConfig, getWorklogApi, INSTALL_DIR, TOKEN_FILE, CONFIG_FILE, LOG_FILE, } from "./config.js";
|
package/dist/postinstall.js
CHANGED
|
@@ -50,7 +50,6 @@ else if (isGlobal) {
|
|
|
50
50
|
log(" 1. Open a browser for you to log in to Kalvium");
|
|
51
51
|
log(" 2. Auto-discover your internship position ID");
|
|
52
52
|
log(" 3. Set up daily auto-submit at your chosen time");
|
|
53
|
-
log(" 4. Generate a phone web app");
|
|
54
53
|
log("");
|
|
55
54
|
log(" Or set up step by step:");
|
|
56
55
|
log(" " + BOLD + "kalvium-worklog login" + NC + " log in");
|
package/dist/submit.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
export interface SubmitOptions {
|
|
2
2
|
text?: string;
|
|
3
|
-
status?: string;
|
|
4
3
|
dryRun?: boolean;
|
|
5
4
|
}
|
|
6
5
|
export interface SubmitResult {
|
|
@@ -8,7 +7,6 @@ export interface SubmitResult {
|
|
|
8
7
|
message: string;
|
|
9
8
|
worklogId?: string;
|
|
10
9
|
}
|
|
11
|
-
export declare const STATUS_OPTIONS: Record<string, string>;
|
|
12
10
|
/**
|
|
13
11
|
* Submit worklog with auto-relogin retry.
|
|
14
12
|
* If the refresh token has expired, automatically opens a browser
|
package/dist/submit.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { getWorklogApi, log } from "./config.js";
|
|
2
2
|
import { refreshToken } from "./refresh.js";
|
|
3
3
|
import { captureToken } from "./capture-token.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
remote: "Working Remotely (Not in the Kalvium environment)",
|
|
7
|
-
classroom: "Working out of the Kalvium environment (Classroom)",
|
|
8
|
-
holiday: "Today was a company Holiday",
|
|
9
|
-
leave: "Took an approved leave from work",
|
|
10
|
-
};
|
|
4
|
+
const STATUS_KEY = "on_site";
|
|
5
|
+
const STATUS_LABEL = "Working on-site (Company location)";
|
|
11
6
|
/**
|
|
12
7
|
* Submit worklog with auto-relogin retry.
|
|
13
8
|
* If the refresh token has expired, automatically opens a browser
|
|
@@ -15,7 +10,6 @@ export const STATUS_OPTIONS = {
|
|
|
15
10
|
*/
|
|
16
11
|
export async function submitWorklog(options = {}) {
|
|
17
12
|
const text = options.text ?? "Working on assigned task";
|
|
18
|
-
const statusKey = options.status ?? "on_site";
|
|
19
13
|
const dryRun = options.dryRun ?? false;
|
|
20
14
|
const worklogApi = getWorklogApi();
|
|
21
15
|
if (!worklogApi) {
|
|
@@ -24,11 +18,10 @@ export async function submitWorklog(options = {}) {
|
|
|
24
18
|
message: "No config found. Run `kalvium-worklog discover` first.",
|
|
25
19
|
};
|
|
26
20
|
}
|
|
27
|
-
const statusLabel = STATUS_OPTIONS[statusKey] ?? STATUS_OPTIONS.on_site;
|
|
28
21
|
const body = JSON.stringify({
|
|
29
|
-
title:
|
|
30
|
-
description:
|
|
31
|
-
category:
|
|
22
|
+
title: STATUS_LABEL,
|
|
23
|
+
description: STATUS_LABEL,
|
|
24
|
+
category: STATUS_KEY,
|
|
32
25
|
timeSpent: 0,
|
|
33
26
|
priorityLevel: "medium",
|
|
34
27
|
blockers: "",
|
|
@@ -36,7 +29,7 @@ export async function submitWorklog(options = {}) {
|
|
|
36
29
|
});
|
|
37
30
|
if (dryRun) {
|
|
38
31
|
console.log(`[DRY RUN] Would submit to ${worklogApi}`);
|
|
39
|
-
console.log(` text='${text}', status=${
|
|
32
|
+
console.log(` text='${text}', status=${STATUS_KEY}`);
|
|
40
33
|
console.log(` Body: ${body}`);
|
|
41
34
|
return { success: true, message: "Dry run" };
|
|
42
35
|
}
|
|
@@ -196,7 +189,6 @@ export async function dailySubmit() {
|
|
|
196
189
|
}
|
|
197
190
|
const result = await submitWorklog({
|
|
198
191
|
text: "Working on assigned task",
|
|
199
|
-
status: "on_site",
|
|
200
192
|
});
|
|
201
193
|
if (result.success) {
|
|
202
194
|
log(`[INFO] SUCCESS: ${result.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kalvium-worklog",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Auto-submit your daily Kalvium worklog without opening the website",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kalvium",
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"kworklog": "dist/cli.js"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
|
-
"dist"
|
|
31
|
-
"templates"
|
|
30
|
+
"dist"
|
|
32
31
|
],
|
|
33
32
|
"scripts": {
|
|
34
33
|
"build": "tsc",
|
|
@@ -1,423 +0,0 @@
|
|
|
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, maximum-scale=1, user-scalable=no">
|
|
6
|
-
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
-
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
8
|
-
<meta name="apple-mobile-web-app-title" content="Worklog">
|
|
9
|
-
<meta name="theme-color" content="#0f172a">
|
|
10
|
-
<title>Kalvium Worklog</title>
|
|
11
|
-
<style>
|
|
12
|
-
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
13
|
-
body {
|
|
14
|
-
font-family: -apple-system, sans-serif;
|
|
15
|
-
background: #0f172a; color: #f8fafc;
|
|
16
|
-
min-height: 100vh; display: flex; flex-direction: column;
|
|
17
|
-
align-items: center; justify-content: center; padding: 20px;
|
|
18
|
-
-webkit-user-select: none; user-select: none;
|
|
19
|
-
}
|
|
20
|
-
.card {
|
|
21
|
-
background: #1e293b; border-radius: 20px; padding: 28px 22px;
|
|
22
|
-
width: 100%; max-width: 380px; box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
|
23
|
-
}
|
|
24
|
-
.header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
|
25
|
-
.logo { width: 36px; height: 36px; border-radius: 10px; background: #3b82f6;
|
|
26
|
-
display: flex; align-items: center; justify-content: center;
|
|
27
|
-
font-weight: 800; font-size: 18px; color: white; flex-shrink: 0; }
|
|
28
|
-
h1 { font-size: 20px; font-weight: 700; }
|
|
29
|
-
.sub { color: #64748b; font-size: 13px; margin-bottom: 20px; }
|
|
30
|
-
textarea {
|
|
31
|
-
width: 100%; min-height: 80px; border-radius: 12px;
|
|
32
|
-
border: 2px solid #334155; background: #0f172a; color: #f8fafc;
|
|
33
|
-
padding: 14px; font-size: 16px; resize: none; margin-bottom: 14px;
|
|
34
|
-
-webkit-user-select: text; user-select: text; font-family: inherit;
|
|
35
|
-
}
|
|
36
|
-
textarea:focus { outline: none; border-color: #3b82f6; }
|
|
37
|
-
.btn {
|
|
38
|
-
width: 100%; padding: 15px; border-radius: 12px; border: none;
|
|
39
|
-
font-size: 16px; font-weight: 600; cursor: pointer; transition: 0.15s;
|
|
40
|
-
-webkit-user-select: none; user-select: none;
|
|
41
|
-
}
|
|
42
|
-
.btn-submit { background: #3b82f6; color: white; margin-bottom: 8px; }
|
|
43
|
-
.btn-submit:active { transform: scale(0.97); background: #2563eb; }
|
|
44
|
-
.btn-submit:disabled { opacity: 0.4; }
|
|
45
|
-
.btn-status { background: #334155; color: #94a3b8; font-size: 14px; }
|
|
46
|
-
.btn-status:active { transform: scale(0.97); }
|
|
47
|
-
.btn-setup { background: transparent; color: #64748b; font-size: 13px;
|
|
48
|
-
margin-top: 12px; text-decoration: underline; }
|
|
49
|
-
#result {
|
|
50
|
-
margin-top: 14px; padding: 12px 14px; border-radius: 10px;
|
|
51
|
-
font-size: 14px; display: none; line-height: 1.4;
|
|
52
|
-
}
|
|
53
|
-
.success { background: #064e3b; color: #6ee7b7; }
|
|
54
|
-
.error { background: #7f1d1d; color: #fca5a5; }
|
|
55
|
-
.loading { background: #1e293b; color: #94a3b8; }
|
|
56
|
-
.spinner { display: inline-block; width: 14px; height: 14px;
|
|
57
|
-
border: 2px solid #64748b; border-top-color: #f8fafc;
|
|
58
|
-
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
59
|
-
vertical-align: middle; margin-right: 6px; }
|
|
60
|
-
@keyframes spin { to { transform: rotate(360deg); } }
|
|
61
|
-
.setup-panel { display: none; margin-top: 16px; }
|
|
62
|
-
.setup-panel textarea { min-height: 120px; font-size: 12px; }
|
|
63
|
-
.token-status { font-size: 12px; color: #64748b; text-align: center; margin-top: 10px; }
|
|
64
|
-
.setup-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
65
|
-
.tab-btn { flex: 1; padding: 10px; border-radius: 8px; border: none;
|
|
66
|
-
font-size: 13px; cursor: pointer; background: #0f172a; color: #64748b; }
|
|
67
|
-
.tab-btn.active { background: #3b82f6; color: white; }
|
|
68
|
-
.tab-content { display: none; }
|
|
69
|
-
.tab-content.active { display: block; }
|
|
70
|
-
.hint { font-size: 12px; color: #64748b; margin-top: 8px; line-height: 1.5; }
|
|
71
|
-
</style>
|
|
72
|
-
</head>
|
|
73
|
-
<body>
|
|
74
|
-
<div class="card">
|
|
75
|
-
<div class="header">
|
|
76
|
-
<div class="logo">K</div>
|
|
77
|
-
<div>
|
|
78
|
-
<h1>Kalvium Worklog</h1>
|
|
79
|
-
<div class="sub" id="dateDisplay"></div>
|
|
80
|
-
</div>
|
|
81
|
-
</div>
|
|
82
|
-
|
|
83
|
-
<textarea id="text" placeholder="What did you worked on today?">Working on assigned task</textarea>
|
|
84
|
-
|
|
85
|
-
<button class="btn btn-submit" id="submitBtn" onclick="submitWorklog()">Submit Worklog</button>
|
|
86
|
-
<button class="btn btn-status" onclick="checkStatus()">Check Status</button>
|
|
87
|
-
<button class="btn btn-setup" onclick="toggleSetup()">Setup / Token</button>
|
|
88
|
-
|
|
89
|
-
<div class="setup-panel" id="setupPanel">
|
|
90
|
-
<div class="setup-tabs">
|
|
91
|
-
<button class="tab-btn active" onclick="switchTab('paste')">Paste Token</button>
|
|
92
|
-
<button class="tab-btn" onclick="switchTab('url')">From URL</button>
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
<div class="tab-content active" id="tab-paste">
|
|
96
|
-
<p class="sub" style="margin-bottom:10px">Paste token JSON from computer setup:</p>
|
|
97
|
-
<textarea id="tokenInput" placeholder='{"refresh_token":"eyJ...","access_token":"eyJ..."}'></textarea>
|
|
98
|
-
<button class="btn btn-submit" onclick="saveToken()" style="margin-top:8px">Save Token</button>
|
|
99
|
-
</div>
|
|
100
|
-
|
|
101
|
-
<div class="tab-content" id="tab-url">
|
|
102
|
-
<p class="sub" style="margin-bottom:10px">If you opened a link with a token, it will be loaded automatically.</p>
|
|
103
|
-
<p class="hint" id="urlStatus">No token found in URL.</p>
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
106
|
-
|
|
107
|
-
<div class="token-status" id="tokenStatus"></div>
|
|
108
|
-
<div id="result"></div>
|
|
109
|
-
</div>
|
|
110
|
-
|
|
111
|
-
<script>
|
|
112
|
-
// ─── Config ──────────────────────────────────────────────────────────────
|
|
113
|
-
const KEYCLOAK_URL = "https://auth.kalvium.community/auth/realms/kalvium/protocol/openid-connect/token";
|
|
114
|
-
const CLIENT_ID = "login_client";
|
|
115
|
-
const WORK_STATUS = "Working on-site (Company location)";
|
|
116
|
-
const CATEGORY = "on_site";
|
|
117
|
-
const API_BASE = "https://student-api.kalvium.community/api/internships/worklogs";
|
|
118
|
-
|
|
119
|
-
// ─── JWT decode (extract position_id from token sub claim) ───────────────
|
|
120
|
-
function decodeJwt(token) {
|
|
121
|
-
try {
|
|
122
|
-
const payload = token.split('.')[1];
|
|
123
|
-
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
124
|
-
return JSON.parse(decoded);
|
|
125
|
-
} catch (e) { return null; }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function getPositionId() {
|
|
129
|
-
const tokens = getTokenData();
|
|
130
|
-
if (!tokens || !tokens.access_token) return null;
|
|
131
|
-
const payload = decodeJwt(tokens.access_token);
|
|
132
|
-
return payload ? payload.sub : null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function getWorklogApi() {
|
|
136
|
-
const posId = getPositionId();
|
|
137
|
-
return posId ? `${API_BASE}/${posId}` : null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ─── Token Management ────────────────────────────────────────────────────
|
|
141
|
-
function getTokenData() {
|
|
142
|
-
try { return JSON.parse(localStorage.getItem("kalvium_tokens")); }
|
|
143
|
-
catch { return null; }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function saveTokenData(data) {
|
|
147
|
-
localStorage.setItem("kalvium_tokens", JSON.stringify(data));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
let pendingAction = null;
|
|
151
|
-
|
|
152
|
-
async function refreshAccessToken() {
|
|
153
|
-
const tokens = getTokenData();
|
|
154
|
-
if (!tokens || !tokens.refresh_token) {
|
|
155
|
-
const err = new Error("No token stored");
|
|
156
|
-
err.code = "NO_TOKEN";
|
|
157
|
-
throw err;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const body = new URLSearchParams({
|
|
161
|
-
grant_type: "refresh_token",
|
|
162
|
-
client_id: CLIENT_ID,
|
|
163
|
-
refresh_token: tokens.refresh_token
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const res = await fetch(KEYCLOAK_URL, {
|
|
167
|
-
method: "POST",
|
|
168
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
169
|
-
body: body
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const data = await res.json();
|
|
173
|
-
if (data.error) {
|
|
174
|
-
const err = new Error(`Token expired (${data.error})`);
|
|
175
|
-
err.code = "TOKEN_EXPIRED";
|
|
176
|
-
throw err;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
saveTokenData(data);
|
|
180
|
-
updateTokenStatus();
|
|
181
|
-
return data.access_token;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function showTokenExpiredUI(originalError) {
|
|
185
|
-
const result = document.getElementById("result");
|
|
186
|
-
result.style.display = "block";
|
|
187
|
-
result.className = "error";
|
|
188
|
-
result.innerHTML = `
|
|
189
|
-
✗ ${originalError.message}<br><br>
|
|
190
|
-
<b>Token expired.</b> Get a new token from your computer:<br>
|
|
191
|
-
1. Run <code>python ~/.kalvium/capture_token.py</code> on your computer<br>
|
|
192
|
-
2. Copy the token JSON<br>
|
|
193
|
-
3. Tap "Setup / Token" below and paste it<br>
|
|
194
|
-
4. Your action will retry automatically
|
|
195
|
-
`;
|
|
196
|
-
document.getElementById("setupPanel").style.display = "block";
|
|
197
|
-
document.getElementById("tokenInput").focus();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ─── Submit Worklog ──────────────────────────────────────────────────────
|
|
201
|
-
async function submitWorklog() {
|
|
202
|
-
const text = document.getElementById("text").value.trim();
|
|
203
|
-
if (!text) return alert("Enter some text first");
|
|
204
|
-
|
|
205
|
-
pendingAction = { type: 'submit', text: text };
|
|
206
|
-
await doSubmit(text);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function doSubmit(text) {
|
|
210
|
-
const btn = document.getElementById("submitBtn");
|
|
211
|
-
const result = document.getElementById("result");
|
|
212
|
-
btn.disabled = true;
|
|
213
|
-
result.style.display = "block";
|
|
214
|
-
result.className = "loading";
|
|
215
|
-
result.innerHTML = '<span class="spinner"></span>Refreshing token...';
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
const accessToken = await refreshAccessToken();
|
|
219
|
-
const worklogApi = getWorklogApi();
|
|
220
|
-
if (!worklogApi) throw new Error("Could not determine position ID from token");
|
|
221
|
-
|
|
222
|
-
result.innerHTML = '<span class="spinner"></span>Submitting worklog...';
|
|
223
|
-
|
|
224
|
-
const body = JSON.stringify({
|
|
225
|
-
title: WORK_STATUS, description: WORK_STATUS, category: CATEGORY,
|
|
226
|
-
timeSpent: 0, priorityLevel: "medium", blockers: "",
|
|
227
|
-
worklogs: JSON.stringify({ content: `<p>${text}</p>` })
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const res = await fetch(worklogApi, {
|
|
231
|
-
method: "PUT",
|
|
232
|
-
headers: {
|
|
233
|
-
"authorization": `Bearer ${accessToken}`,
|
|
234
|
-
"content-type": "application/json",
|
|
235
|
-
"origin": "https://kalvium.community",
|
|
236
|
-
"referer": "https://kalvium.community/"
|
|
237
|
-
},
|
|
238
|
-
body: body
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const data = await res.json();
|
|
242
|
-
if (res.ok && data.worklogId) {
|
|
243
|
-
result.className = "success";
|
|
244
|
-
result.innerHTML = `✓ Submitted!<br>ID: ${data.worklogId} | Status: ${data.status}`;
|
|
245
|
-
pendingAction = null;
|
|
246
|
-
} else if (res.status === 401 || res.status === 403) {
|
|
247
|
-
const err = new Error("Token rejected by server");
|
|
248
|
-
err.code = "TOKEN_EXPIRED";
|
|
249
|
-
throw err;
|
|
250
|
-
} else if (res.status === 404) {
|
|
251
|
-
result.className = "success";
|
|
252
|
-
result.innerHTML = "✓ Already submitted today!";
|
|
253
|
-
pendingAction = null;
|
|
254
|
-
} else {
|
|
255
|
-
result.className = "error";
|
|
256
|
-
result.innerHTML = `✗ ${data.message || "Failed"}`;
|
|
257
|
-
}
|
|
258
|
-
} catch (e) {
|
|
259
|
-
if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
|
|
260
|
-
showTokenExpiredUI(e);
|
|
261
|
-
} else {
|
|
262
|
-
result.className = "error";
|
|
263
|
-
result.innerHTML = `✗ ${e.message}`;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
btn.disabled = false;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ─── Check Status ────────────────────────────────────────────────────────
|
|
270
|
-
async function checkStatus() {
|
|
271
|
-
pendingAction = { type: 'status' };
|
|
272
|
-
await doCheckStatus();
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function doCheckStatus() {
|
|
276
|
-
const result = document.getElementById("result");
|
|
277
|
-
result.style.display = "block";
|
|
278
|
-
result.className = "loading";
|
|
279
|
-
result.innerHTML = '<span class="spinner"></span>Checking...';
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const accessToken = await refreshAccessToken();
|
|
283
|
-
const worklogApi = getWorklogApi();
|
|
284
|
-
if (!worklogApi) throw new Error("Could not determine position ID from token");
|
|
285
|
-
|
|
286
|
-
const res = await fetch(worklogApi, {
|
|
287
|
-
headers: { "authorization": `Bearer ${accessToken}` }
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
if (res.status === 401 || res.status === 403) {
|
|
291
|
-
const err = new Error("Token rejected by server");
|
|
292
|
-
err.code = "TOKEN_EXPIRED";
|
|
293
|
-
throw err;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const worklogs = await res.json();
|
|
297
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
298
|
-
|
|
299
|
-
let html = "";
|
|
300
|
-
for (const w of worklogs.slice(0, 5)) {
|
|
301
|
-
const date = (w.worklogDate || "").slice(0, 10);
|
|
302
|
-
const status = w.status || "?";
|
|
303
|
-
let content = "";
|
|
304
|
-
if (w.worklogs) content = (w.worklogs.content || "").replace(/<\/?p>/g, "");
|
|
305
|
-
const marker = date === today ? " ← TODAY" : "";
|
|
306
|
-
html += `${date} | ${status} | ${content}${marker}<br>`;
|
|
307
|
-
}
|
|
308
|
-
result.className = "success";
|
|
309
|
-
result.innerHTML = html;
|
|
310
|
-
pendingAction = null;
|
|
311
|
-
} catch (e) {
|
|
312
|
-
if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
|
|
313
|
-
showTokenExpiredUI(e);
|
|
314
|
-
} else {
|
|
315
|
-
result.className = "error";
|
|
316
|
-
result.innerHTML = `✗ ${e.message}`;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ─── Setup ───────────────────────────────────────────────────────────────
|
|
322
|
-
function toggleSetup() {
|
|
323
|
-
document.getElementById("setupPanel").style.display =
|
|
324
|
-
document.getElementById("setupPanel").style.display === "block" ? "none" : "block";
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function switchTab(tab) {
|
|
328
|
-
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
|
329
|
-
document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active"));
|
|
330
|
-
event.target.classList.add("active");
|
|
331
|
-
document.getElementById("tab-" + tab).classList.add("active");
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function saveToken() {
|
|
335
|
-
const input = document.getElementById("tokenInput").value.trim();
|
|
336
|
-
try {
|
|
337
|
-
const data = JSON.parse(input);
|
|
338
|
-
if (data.refresh_token) {
|
|
339
|
-
saveTokenData(data);
|
|
340
|
-
updateTokenStatus();
|
|
341
|
-
document.getElementById("tokenInput").value = "";
|
|
342
|
-
document.getElementById("setupPanel").style.display = "none";
|
|
343
|
-
|
|
344
|
-
// Auto-retry the pending action if there was one
|
|
345
|
-
if (pendingAction) {
|
|
346
|
-
if (pendingAction.type === 'submit') {
|
|
347
|
-
alert("Token saved! Retrying your submission...");
|
|
348
|
-
doSubmit(pendingAction.text);
|
|
349
|
-
} else if (pendingAction.type === 'status') {
|
|
350
|
-
alert("Token saved! Retrying status check...");
|
|
351
|
-
doCheckStatus();
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
alert("Token saved! You can now submit worklogs.");
|
|
355
|
-
}
|
|
356
|
-
} else {
|
|
357
|
-
alert("Invalid token JSON — no refresh_token found");
|
|
358
|
-
}
|
|
359
|
-
} catch (e) {
|
|
360
|
-
alert("Invalid JSON: " + e.message);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function updateTokenStatus() {
|
|
365
|
-
const tokens = getTokenData();
|
|
366
|
-
const el = document.getElementById("tokenStatus");
|
|
367
|
-
if (tokens && tokens.refresh_token) {
|
|
368
|
-
const posId = getPositionId();
|
|
369
|
-
el.textContent = `✓ Token stored${posId ? ' (ID: ' + posId.slice(0,8) + '...)' : ''}`;
|
|
370
|
-
el.style.color = "#6ee7b7";
|
|
371
|
-
} else {
|
|
372
|
-
el.textContent = "⚠ No token — tap Setup to add";
|
|
373
|
-
el.style.color = "#fca5a5";
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ─── URL token loading (for hosted version) ──────────────────────────────
|
|
378
|
-
function loadTokenFromUrl() {
|
|
379
|
-
// Check URL hash for token (e.g., #token=base64encodedjson)
|
|
380
|
-
const hash = window.location.hash;
|
|
381
|
-
if (hash.startsWith("#token=")) {
|
|
382
|
-
try {
|
|
383
|
-
const encoded = hash.substring(7);
|
|
384
|
-
const decoded = atob(encoded);
|
|
385
|
-
const data = JSON.parse(decoded);
|
|
386
|
-
if (data.refresh_token) {
|
|
387
|
-
saveTokenData(data);
|
|
388
|
-
updateTokenStatus();
|
|
389
|
-
document.getElementById("urlStatus").textContent = "✓ Token loaded from URL!";
|
|
390
|
-
document.getElementById("urlStatus").style.color = "#6ee7b7";
|
|
391
|
-
// Clean the URL
|
|
392
|
-
history.replaceState(null, "", window.location.pathname);
|
|
393
|
-
}
|
|
394
|
-
} catch (e) {
|
|
395
|
-
document.getElementById("urlStatus").textContent = "✗ Invalid token in URL";
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
// Also check query param
|
|
399
|
-
const params = new URLSearchParams(window.location.search);
|
|
400
|
-
const tokenParam = params.get("token");
|
|
401
|
-
if (tokenParam) {
|
|
402
|
-
try {
|
|
403
|
-
const data = JSON.parse(atob(tokenParam));
|
|
404
|
-
if (data.refresh_token) {
|
|
405
|
-
saveTokenData(data);
|
|
406
|
-
updateTokenStatus();
|
|
407
|
-
document.getElementById("urlStatus").textContent = "✓ Token loaded from URL!";
|
|
408
|
-
document.getElementById("urlStatus").style.color = "#6ee7b7";
|
|
409
|
-
history.replaceState(null, "", window.location.pathname);
|
|
410
|
-
}
|
|
411
|
-
} catch (e) {}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// ─── Init ────────────────────────────────────────────────────────────────
|
|
416
|
-
document.getElementById("dateDisplay").textContent = new Date().toLocaleDateString("en-US", {
|
|
417
|
-
weekday: "long", year: "numeric", month: "long", day: "numeric"
|
|
418
|
-
});
|
|
419
|
-
loadTokenFromUrl();
|
|
420
|
-
updateTokenStatus();
|
|
421
|
-
</script>
|
|
422
|
-
</body>
|
|
423
|
-
</html>
|
|
@@ -1,387 +0,0 @@
|
|
|
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, maximum-scale=1, user-scalable=no">
|
|
6
|
-
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
-
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
8
|
-
<meta name="apple-mobile-web-app-title" content="Worklog">
|
|
9
|
-
<meta name="theme-color" content="#0f172a">
|
|
10
|
-
<title>Kalvium Worklog</title>
|
|
11
|
-
<style>
|
|
12
|
-
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
13
|
-
body {
|
|
14
|
-
font-family: -apple-system, sans-serif;
|
|
15
|
-
background: #0f172a; color: #f8fafc;
|
|
16
|
-
min-height: 100vh; display: flex; flex-direction: column;
|
|
17
|
-
align-items: center; justify-content: center; padding: 20px;
|
|
18
|
-
-webkit-user-select: none; user-select: none;
|
|
19
|
-
}
|
|
20
|
-
.card {
|
|
21
|
-
background: #1e293b; border-radius: 20px; padding: 28px 22px;
|
|
22
|
-
width: 100%; max-width: 380px; box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
|
23
|
-
}
|
|
24
|
-
.header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
|
25
|
-
.logo { width: 36px; height: 36px; border-radius: 10px; background: #3b82f6;
|
|
26
|
-
display: flex; align-items: center; justify-content: center;
|
|
27
|
-
font-weight: 800; font-size: 18px; color: white; flex-shrink: 0; }
|
|
28
|
-
h1 { font-size: 20px; font-weight: 700; }
|
|
29
|
-
.sub { color: #64748b; font-size: 13px; margin-bottom: 20px; }
|
|
30
|
-
textarea {
|
|
31
|
-
width: 100%; min-height: 80px; border-radius: 12px;
|
|
32
|
-
border: 2px solid #334155; background: #0f172a; color: #f8fafc;
|
|
33
|
-
padding: 14px; font-size: 16px; resize: none; margin-bottom: 14px;
|
|
34
|
-
-webkit-user-select: text; user-select: text; font-family: inherit;
|
|
35
|
-
}
|
|
36
|
-
textarea:focus { outline: none; border-color: #3b82f6; }
|
|
37
|
-
.btn {
|
|
38
|
-
width: 100%; padding: 15px; border-radius: 12px; border: none;
|
|
39
|
-
font-size: 16px; font-weight: 600; cursor: pointer; transition: 0.15s;
|
|
40
|
-
-webkit-user-select: none; user-select: none;
|
|
41
|
-
}
|
|
42
|
-
.btn-submit { background: #3b82f6; color: white; margin-bottom: 8px; }
|
|
43
|
-
.btn-submit:active { transform: scale(0.97); background: #2563eb; }
|
|
44
|
-
.btn-submit:disabled { opacity: 0.4; }
|
|
45
|
-
.btn-status { background: #334155; color: #94a3b8; font-size: 14px; }
|
|
46
|
-
.btn-status:active { transform: scale(0.97); }
|
|
47
|
-
.btn-setup { background: transparent; color: #64748b; font-size: 13px;
|
|
48
|
-
margin-top: 12px; text-decoration: underline; }
|
|
49
|
-
#result {
|
|
50
|
-
margin-top: 14px; padding: 12px 14px; border-radius: 10px;
|
|
51
|
-
font-size: 14px; display: none; line-height: 1.4;
|
|
52
|
-
}
|
|
53
|
-
.success { background: #064e3b; color: #6ee7b7; }
|
|
54
|
-
.error { background: #7f1d1d; color: #fca5a5; }
|
|
55
|
-
.loading { background: #1e293b; color: #94a3b8; }
|
|
56
|
-
.spinner { display: inline-block; width: 14px; height: 14px;
|
|
57
|
-
border: 2px solid #64748b; border-top-color: #f8fafc;
|
|
58
|
-
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
59
|
-
vertical-align: middle; margin-right: 6px; }
|
|
60
|
-
@keyframes spin { to { transform: rotate(360deg); } }
|
|
61
|
-
.setup-panel { display: none; margin-top: 16px; }
|
|
62
|
-
.setup-panel textarea { min-height: 120px; font-size: 12px; }
|
|
63
|
-
.token-status { font-size: 12px; color: #64748b; text-align: center; margin-top: 10px; }
|
|
64
|
-
.qr-section { margin-top: 16px; text-align: center; }
|
|
65
|
-
.qr-section canvas { margin: 10px auto; display: block; }
|
|
66
|
-
.qr-hint { font-size: 12px; color: #64748b; margin-top: 8px; }
|
|
67
|
-
</style>
|
|
68
|
-
</head>
|
|
69
|
-
<body>
|
|
70
|
-
<div class="card">
|
|
71
|
-
<div class="header">
|
|
72
|
-
<div class="logo">K</div>
|
|
73
|
-
<div>
|
|
74
|
-
<h1>Kalvium Worklog</h1>
|
|
75
|
-
<div class="sub" id="dateDisplay"></div>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
<textarea id="text" placeholder="What did you work on today?">Working on assigned task</textarea>
|
|
80
|
-
|
|
81
|
-
<button class="btn btn-submit" id="submitBtn" onclick="submitWorklog()">Submit Worklog</button>
|
|
82
|
-
<button class="btn btn-status" onclick="checkStatus()">Check Status</button>
|
|
83
|
-
<button class="btn btn-setup" onclick="toggleSetup()">Setup / Token</button>
|
|
84
|
-
|
|
85
|
-
<div class="setup-panel" id="setupPanel">
|
|
86
|
-
<p class="sub" style="margin-bottom:10px">Paste token JSON from computer, or scan QR code:</p>
|
|
87
|
-
<textarea id="tokenInput" placeholder='Paste refresh token JSON here'></textarea>
|
|
88
|
-
<button class="btn btn-submit" onclick="saveToken()" style="margin-top:8px">Save Token</button>
|
|
89
|
-
|
|
90
|
-
<div class="qr-section" id="qrSection">
|
|
91
|
-
<p class="sub">Or scan QR code from computer:</p>
|
|
92
|
-
<button class="btn btn-status" onclick="scanQR()">Scan QR Code</button>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
<div class="token-status" id="tokenStatus"></div>
|
|
97
|
-
<div id="result"></div>
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
<script>
|
|
101
|
-
// ─── Config ──────────────────────────────────────────────────────────────
|
|
102
|
-
const KEYCLOAK_URL = "https://auth.kalvium.community/auth/realms/kalvium/protocol/openid-connect/token";
|
|
103
|
-
const CLIENT_ID = "login_client";
|
|
104
|
-
const WORK_STATUS = "Working on-site (Company location)";
|
|
105
|
-
const CATEGORY = "on_site";
|
|
106
|
-
const API_BASE = "https://student-api.kalvium.community/api/internships/worklogs";
|
|
107
|
-
|
|
108
|
-
// ─── JWT decode (extract position_id from token) ─────────────────────────
|
|
109
|
-
function decodeJwt(token) {
|
|
110
|
-
try {
|
|
111
|
-
const payload = token.split('.')[1];
|
|
112
|
-
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
113
|
-
return JSON.parse(decoded);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function getPositionId() {
|
|
120
|
-
const tokens = getTokenData();
|
|
121
|
-
if (!tokens || !tokens.access_token) return null;
|
|
122
|
-
const payload = decodeJwt(tokens.access_token);
|
|
123
|
-
return payload ? payload.sub : null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function getWorklogApi() {
|
|
127
|
-
const posId = getPositionId();
|
|
128
|
-
if (!posId) return null;
|
|
129
|
-
return `${API_BASE}/${posId}`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ─── Token Management ────────────────────────────────────────────────────
|
|
133
|
-
function getTokenData() {
|
|
134
|
-
try { return JSON.parse(localStorage.getItem("kalvium_tokens")); }
|
|
135
|
-
catch { return null; }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function saveTokenData(data) {
|
|
139
|
-
localStorage.setItem("kalvium_tokens", JSON.stringify(data));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Track pending action for retry after re-login
|
|
143
|
-
let pendingAction = null; // 'submit' or 'status'
|
|
144
|
-
|
|
145
|
-
async function refreshAccessToken() {
|
|
146
|
-
const tokens = getTokenData();
|
|
147
|
-
if (!tokens || !tokens.refresh_token) {
|
|
148
|
-
const err = new Error("No token stored");
|
|
149
|
-
err.code = "NO_TOKEN";
|
|
150
|
-
throw err;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const body = new URLSearchParams({
|
|
154
|
-
grant_type: "refresh_token",
|
|
155
|
-
client_id: CLIENT_ID,
|
|
156
|
-
refresh_token: tokens.refresh_token
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const res = await fetch(KEYCLOAK_URL, {
|
|
160
|
-
method: "POST",
|
|
161
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
162
|
-
body: body
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const data = await res.json();
|
|
166
|
-
if (data.error) {
|
|
167
|
-
const err = new Error(`Token expired (${data.error})`);
|
|
168
|
-
err.code = "TOKEN_EXPIRED";
|
|
169
|
-
err.data = data;
|
|
170
|
-
throw err;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Save new tokens (refresh token rotates)
|
|
174
|
-
saveTokenData(data);
|
|
175
|
-
updateTokenStatus();
|
|
176
|
-
return data.access_token;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Show "token expired" UI and prompt user to paste a new one
|
|
180
|
-
function showTokenExpiredUI(originalError) {
|
|
181
|
-
const result = document.getElementById("result");
|
|
182
|
-
result.style.display = "block";
|
|
183
|
-
result.className = "error";
|
|
184
|
-
result.innerHTML = `
|
|
185
|
-
✗ ${originalError.message}<br><br>
|
|
186
|
-
<b>Token expired.</b> Get a new token from your computer:<br>
|
|
187
|
-
1. Run <code>python ~/.kalvium/capture_token.py</code> on your computer<br>
|
|
188
|
-
2. Copy the token JSON<br>
|
|
189
|
-
3. Tap "Setup / Token" below and paste it<br>
|
|
190
|
-
4. Your action will retry automatically
|
|
191
|
-
`;
|
|
192
|
-
// Auto-open the setup panel
|
|
193
|
-
document.getElementById("setupPanel").style.display = "block";
|
|
194
|
-
document.getElementById("tokenInput").focus();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ─── Submit Worklog ──────────────────────────────────────────────────────
|
|
198
|
-
async function submitWorklog() {
|
|
199
|
-
const text = document.getElementById("text").value.trim();
|
|
200
|
-
if (!text) return alert("Enter some text first");
|
|
201
|
-
|
|
202
|
-
pendingAction = { type: 'submit', text: text };
|
|
203
|
-
await doSubmit(text);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function doSubmit(text) {
|
|
207
|
-
const btn = document.getElementById("submitBtn");
|
|
208
|
-
const result = document.getElementById("result");
|
|
209
|
-
btn.disabled = true;
|
|
210
|
-
result.style.display = "block";
|
|
211
|
-
result.className = "loading";
|
|
212
|
-
result.innerHTML = '<span class="spinner"></span>Refreshing token...';
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
const accessToken = await refreshAccessToken();
|
|
216
|
-
const worklogApi = getWorklogApi();
|
|
217
|
-
if (!worklogApi) throw new Error("Could not determine position ID from token");
|
|
218
|
-
|
|
219
|
-
result.innerHTML = '<span class="spinner"></span>Submitting worklog...';
|
|
220
|
-
|
|
221
|
-
const body = JSON.stringify({
|
|
222
|
-
title: WORK_STATUS,
|
|
223
|
-
description: WORK_STATUS,
|
|
224
|
-
category: CATEGORY,
|
|
225
|
-
timeSpent: 0,
|
|
226
|
-
priorityLevel: "medium",
|
|
227
|
-
blockers: "",
|
|
228
|
-
worklogs: JSON.stringify({ content: `<p>${text}</p>` })
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
const res = await fetch(worklogApi, {
|
|
232
|
-
method: "PUT",
|
|
233
|
-
headers: {
|
|
234
|
-
"authorization": `Bearer ${accessToken}`,
|
|
235
|
-
"content-type": "application/json",
|
|
236
|
-
"origin": "https://kalvium.community",
|
|
237
|
-
"referer": "https://kalvium.community/"
|
|
238
|
-
},
|
|
239
|
-
body: body
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const data = await res.json();
|
|
243
|
-
|
|
244
|
-
if (res.ok && data.worklogId) {
|
|
245
|
-
result.className = "success";
|
|
246
|
-
result.innerHTML = `✓ Submitted!<br>ID: ${data.worklogId} | Status: ${data.status}`;
|
|
247
|
-
pendingAction = null; // Clear on success
|
|
248
|
-
} else if (res.status === 401 || res.status === 403) {
|
|
249
|
-
// Token invalid at API level — treat as expired
|
|
250
|
-
const err = new Error("Token rejected by server");
|
|
251
|
-
err.code = "TOKEN_EXPIRED";
|
|
252
|
-
throw err;
|
|
253
|
-
} else if (res.status === 404) {
|
|
254
|
-
result.className = "success";
|
|
255
|
-
result.innerHTML = "✓ Already submitted today!";
|
|
256
|
-
pendingAction = null;
|
|
257
|
-
} else {
|
|
258
|
-
result.className = "error";
|
|
259
|
-
result.innerHTML = `✗ ${data.message || "Failed"}`;
|
|
260
|
-
}
|
|
261
|
-
} catch (e) {
|
|
262
|
-
if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
|
|
263
|
-
showTokenExpiredUI(e);
|
|
264
|
-
} else {
|
|
265
|
-
result.className = "error";
|
|
266
|
-
result.innerHTML = `✗ ${e.message}`;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
btn.disabled = false;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ─── Check Status ────────────────────────────────────────────────────────
|
|
273
|
-
async function checkStatus() {
|
|
274
|
-
pendingAction = { type: 'status' };
|
|
275
|
-
await doCheckStatus();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async function doCheckStatus() {
|
|
279
|
-
const result = document.getElementById("result");
|
|
280
|
-
result.style.display = "block";
|
|
281
|
-
result.className = "loading";
|
|
282
|
-
result.innerHTML = '<span class="spinner"></span>Checking...';
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
const accessToken = await refreshAccessToken();
|
|
286
|
-
const worklogApi = getWorklogApi();
|
|
287
|
-
if (!worklogApi) throw new Error("Could not determine position ID from token");
|
|
288
|
-
|
|
289
|
-
const res = await fetch(worklogApi, {
|
|
290
|
-
headers: { "authorization": `Bearer ${accessToken}` }
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
if (res.status === 401 || res.status === 403) {
|
|
294
|
-
const err = new Error("Token rejected by server");
|
|
295
|
-
err.code = "TOKEN_EXPIRED";
|
|
296
|
-
throw err;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const worklogs = await res.json();
|
|
300
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
301
|
-
|
|
302
|
-
let html = "";
|
|
303
|
-
for (const w of worklogs.slice(0, 5)) {
|
|
304
|
-
const date = (w.worklogDate || "").slice(0, 10);
|
|
305
|
-
const status = w.status || "?";
|
|
306
|
-
let content = "";
|
|
307
|
-
if (w.worklogs) content = (w.worklogs.content || "").replace(/<\/?p>/g, "");
|
|
308
|
-
const marker = date === today ? " ← TODAY" : "";
|
|
309
|
-
html += `${date} | ${status} | ${content}${marker}<br>`;
|
|
310
|
-
}
|
|
311
|
-
result.className = "success";
|
|
312
|
-
result.innerHTML = html;
|
|
313
|
-
pendingAction = null; // Clear on success
|
|
314
|
-
} catch (e) {
|
|
315
|
-
if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
|
|
316
|
-
showTokenExpiredUI(e);
|
|
317
|
-
} else {
|
|
318
|
-
result.className = "error";
|
|
319
|
-
result.innerHTML = `✗ ${e.message}`;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ─── Setup ───────────────────────────────────────────────────────────────
|
|
325
|
-
function toggleSetup() {
|
|
326
|
-
const panel = document.getElementById("setupPanel");
|
|
327
|
-
panel.style.display = panel.style.display === "block" ? "none" : "block";
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function saveToken() {
|
|
331
|
-
const input = document.getElementById("tokenInput").value.trim();
|
|
332
|
-
try {
|
|
333
|
-
const data = JSON.parse(input);
|
|
334
|
-
if (data.refresh_token) {
|
|
335
|
-
saveTokenData(data);
|
|
336
|
-
updateTokenStatus();
|
|
337
|
-
document.getElementById("tokenInput").value = "";
|
|
338
|
-
document.getElementById("setupPanel").style.display = "none";
|
|
339
|
-
|
|
340
|
-
// Auto-retry the pending action if there was one
|
|
341
|
-
if (pendingAction) {
|
|
342
|
-
if (pendingAction.type === 'submit') {
|
|
343
|
-
alert("Token saved! Retrying your submission...");
|
|
344
|
-
doSubmit(pendingAction.text);
|
|
345
|
-
} else if (pendingAction.type === 'status') {
|
|
346
|
-
alert("Token saved! Retrying status check...");
|
|
347
|
-
doCheckStatus();
|
|
348
|
-
}
|
|
349
|
-
} else {
|
|
350
|
-
alert("Token saved! You can now submit worklogs.");
|
|
351
|
-
}
|
|
352
|
-
} else {
|
|
353
|
-
alert("Invalid token JSON — no refresh_token found");
|
|
354
|
-
}
|
|
355
|
-
} catch (e) {
|
|
356
|
-
alert("Invalid JSON: " + e.message);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function scanQR() {
|
|
361
|
-
// Open camera for QR scanning using a simple approach
|
|
362
|
-
// We use a public QR scanner that returns the data via URL
|
|
363
|
-
const qrScannerUrl = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=";
|
|
364
|
-
alert("To scan QR:\n1. Run 'python generate_webapp.py' on your computer\n2. It will show a QR code\n3. Scan it with your phone camera\n4. Copy the text and paste it above");
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function updateTokenStatus() {
|
|
368
|
-
const tokens = getTokenData();
|
|
369
|
-
const el = document.getElementById("tokenStatus");
|
|
370
|
-
if (tokens && tokens.refresh_token) {
|
|
371
|
-
const posId = getPositionId();
|
|
372
|
-
el.textContent = `✓ Token stored${posId ? ' (Position: ' + posId.slice(0,8) + '...)' : ''}`;
|
|
373
|
-
el.style.color = "#6ee7b7";
|
|
374
|
-
} else {
|
|
375
|
-
el.textContent = "⚠ No token — tap Setup to add";
|
|
376
|
-
el.style.color = "#fca5a5";
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ─── Init ────────────────────────────────────────────────────────────────
|
|
381
|
-
document.getElementById("dateDisplay").textContent = new Date().toLocaleDateString("en-US", {
|
|
382
|
-
weekday: "long", year: "numeric", month: "long", day: "numeric"
|
|
383
|
-
});
|
|
384
|
-
updateTokenStatus();
|
|
385
|
-
</script>
|
|
386
|
-
</body>
|
|
387
|
-
</html>
|