Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<html>
<head>
<meta charset="utf-8">
<title>ActivityWatch</title>
<link rel="icon" type="image/png" href="/logo.png">
<title>Prescribing Care Direct</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!-- Verify with https://csp-evaluator.withgoogle.com/ -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' <%= htmlWebpackPlugin.options.templateParameters.cspDefaultSrc %> https://api.github.com/repos/ActivityWatch/activitywatch/releases/latest; img-src 'self' data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; script-src 'self' 'unsafe-eval'">
Expand Down
2 changes: 1 addition & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ div#wrapper(v-if="loaded")
new-release-notification(v-if="isNewReleaseCheckEnabled")
router-view

aw-footer
//- aw-footer
</template>

<script lang="ts">
Expand Down
19 changes: 5 additions & 14 deletions src/components/EventEditor.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template lang="pug">
b-modal(v-if="event && event.id", :id="'edit-modal-' + event.id", ref="eventEditModal", title="Edit event", centered, hide-footer)
b-modal(v-if="event && event.id", :id="'edit-modal-' + event.id", ref="eventEditModal", title="Event details", centered, hide-footer)
div(v-if="!editedEvent")
| Loading event...

Expand All @@ -13,10 +13,10 @@ b-modal(v-if="event && event.id", :id="'edit-modal-' + event.id", ref="eventEdit
td {{ event.id }}
tr
th Start
datetime(type="datetime" v-model="start")
td {{ start }}
tr
th End
datetime(type="datetime" v-model="end")
td {{ end }}
tr
th Duration
td {{ editedEvent.duration | friendlyduration }}
Expand All @@ -31,23 +31,14 @@ b-modal(v-if="event && event.id", :id="'edit-modal-' + event.id", ref="eventEdit
td
b-input(disabled, :value="k", size="sm")
td
b-checkbox(v-if="typeof event.data[k] === typeof true", v-model="editedEvent.data[k]", style="margin: 0.25em")
b-input(v-if="typeof event.data[k] === typeof 'string'", v-model="editedEvent.data[k]", size="sm")
b-input(v-if="typeof event.data[k] === 'number'", v-model.number="editedEvent.data[k]", size="sm", type="number")
b-input(disabled, :value="v", size="sm")

hr

div.float-left
b-button.mx-1(@click="delete_(); close();" variant="danger")
icon.mx-1(name="trash")
| Delete
div.float-right
b-button.mx-1(@click="close")
icon.mx-1(name="times")
| Cancel
b-button.mx-1(@click="save(); close();", variant="primary")
icon.mx-1(name="save")
| Save
| Close
</template>

<style lang="scss"></style>
Expand Down
13 changes: 9 additions & 4 deletions src/components/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
// Brand on mobile
b-navbar-nav.d-block.d-lg-none
b-navbar-brand(to="/" style="background-color: transparent;")
img.aligh-middle(src="/logo.png" style="height: 1.5em;")
span.ml-2.align-middle(style="font-size: 1em; color: #000;") ActivityWatch
img.aligh-middle(src="/logo.svg" style="height: 1.8em;")
span.ml-2.align-middle(style="font-size: 1em; color: #0B9DD0; font-weight: 600;") Prescribing Care Direct

b-navbar-toggle(target="nav-collapse")

Expand Down Expand Up @@ -47,8 +47,8 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
// Brand on large screens (centered)
b-navbar-nav.abs-center.d-none.d-lg-block
b-navbar-brand(to="/" style="background-color: transparent;")
img.ml-0.aligh-middle(src="/logo.png" style="height: 1.5em;")
span.ml-2.align-middle(style="font-size: 1.0em; color: #000;") ActivityWatch
img.ml-0.aligh-middle(src="/logo.svg" style="height: 1.8em;")
span.ml-2.align-middle(style="font-size: 1.0em; color: #0B9DD0; font-weight: 600;") Prescribing Care Direct

b-navbar-nav.ml-auto
b-nav-item-dropdown
Expand Down Expand Up @@ -90,6 +90,10 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
div.px-2.px-lg-1
icon(name="cog")
| Settings
b-nav-item(to="/pcd-admin")
div.px-2.px-lg-1
icon(name="user-shield")
| PCD Admin
</template>

<style lang="scss" scoped>
Expand All @@ -114,6 +118,7 @@ import 'vue-awesome/icons/stopwatch';
import 'vue-awesome/icons/cog';
import 'vue-awesome/icons/tools';
import 'vue-awesome/icons/history';
import 'vue-awesome/icons/user-shield';

// TODO: use circle-nodes instead in the future
import 'vue-awesome/icons/project-diagram';
Expand Down
186 changes: 186 additions & 0 deletions src/components/PCDAdminPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<template lang="pug">
div.pcd-admin-panel
small.pcd-trigger(@click="panelVisible = !panelVisible" title="PCD Admin") PCD
div.pcd-panel(v-if="panelVisible")
hr.mt-1
h6.text-muted.mb-3 PCD Admin
div(v-if="!authenticated")
b-form-group(label="Username" label-cols-md="3" label-size="sm")
b-form-input(
v-model="username"
type="email"
placeholder="admin@example.com"
size="sm"
:disabled="loading"
@keyup.enter="verifyAdmin"
)
b-form-group(label="Password" label-cols-md="3" label-size="sm")
b-input-group(size="sm")
b-form-input(
v-model="password"
type="password"
placeholder="Admin password"
:disabled="loading"
@keyup.enter="verifyAdmin"
)
b-input-group-append
b-button(variant="outline-secondary" size="sm" @click="verifyAdmin" :disabled="loading || !username || !password")
| {{ loading ? 'Checking…' : 'Unlock' }}
b-alert(v-if="authError" show variant="danger" dismissible @dismissed="authError = ''") {{ authError }}
div(v-if="authenticated")
b-form-group(label="PCD user email" label-cols-md="3" label-size="sm")
b-input-group(size="sm")
b-form-input(
v-model="email"
type="email"
placeholder="user@example.com"
:state="emailState"
@keyup.enter="saveEmail"
)
b-input-group-append
b-button(
variant="outline-primary"
size="sm"
@click="saveEmail"
:disabled="saving || !emailChanged || emailState === false"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Save button not disabled for an empty email

The button disables on emailState === false, but emailState returns null (not false) when the field is empty. A user who clears the registered email will see the Save button enabled and be able to submit an empty address.

Suggested change
:disabled="saving || !emailChanged || emailState === false"
:disabled="saving || !emailChanged || emailState !== true"

) {{ saving ? 'Saving…' : 'Save' }}
b-form-invalid-feedback Invalid email address.
b-alert(v-if="saveError" show variant="danger" dismissible @dismissed="saveError = ''") {{ saveError }}
b-alert(v-if="saveSuccess" show variant="success" dismissible @dismissed="saveSuccess = false")
| Email updated. PCD sync will use the new address on the next cycle.
small.text-muted
| Changes take effect without reinstalling the app.
| &#32;
a(href="#" @click.prevent="lock") Sign out
</template>

<script lang="ts">
const API_BASE = '/api/0/pcd';

export default {
name: 'PCDAdminPanel',
data() {
return {
panelVisible: false,
username: '',
password: '',
loading: false,
authError: '',
authenticated: false,
sessionToken: '',
email: '',
originalEmail: '',
saving: false,
saveError: '',
saveSuccess: false,
};
},
computed: {
emailState(): boolean | null {
if (!this.email) return null;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email);
},
emailChanged(): boolean {
return this.email !== this.originalEmail;
},
},
methods: {
async verifyAdmin() {
if (!this.username || !this.password) return;
this.loading = true;
this.authError = '';
try {
const res = await fetch(`${API_BASE}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username, password: this.password }),
});
if (res.ok) {
const data = await res.json();
this.sessionToken = data.token;
this.authenticated = true;
await this.loadEmail();
} else {
const data = await res.json().catch(() => ({}));
this.authError = data.error || 'Invalid credentials.';
this.password = '';
}
} catch {
this.authError = 'Could not reach the local server.';
} finally {
this.loading = false;
}
},
Comment on lines +88 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Inconsistent auth payload vs. PcdAdmin.vue

PCDAdminPanel.vue calls /api/0/pcd/verify with { username, password } (lines 93–96), while PcdAdmin.vue calls the same endpoint with only { password } (line 140). These two components cannot both work correctly against the same backend endpoint. Whichever payload the server actually expects will cause the other component's login to silently fail or error.

async loadEmail() {
try {
const res = await fetch(`${API_BASE}/email`, {
headers: { 'X-PCD-Admin-Token': this.sessionToken },
});
if (res.ok) {
const data = await res.json();
this.email = data.email || '';
this.originalEmail = this.email;
}
} catch {
// non-fatal
}
},
async saveEmail() {
if (!this.emailChanged || this.emailState === false) return;
Comment on lines +128 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty email can bypass the save guard

When the user clears the email field, emailState returns null (not false), so the this.emailState === false guard does not block the call. A PUT request with { new_email: '' } is then sent to the backend. The fix is to treat null as an invalid state in the guard, and also disable the Save button when emailState is null.

Suggested change
async saveEmail() {
if (!this.emailChanged || this.emailState === false) return;
async saveEmail() {
if (!this.emailChanged || this.emailState !== true) return;

this.saving = true;
this.saveError = '';
this.saveSuccess = false;
try {
const res = await fetch(`${API_BASE}/email`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-PCD-Admin-Token': this.sessionToken,
},
body: JSON.stringify({ new_email: this.email }),
});
if (res.ok) {
this.originalEmail = this.email;
this.saveSuccess = true;
} else {
const data = await res.json().catch(() => ({}));
this.saveError = data.error || 'Failed to save email.';
}
} catch {
this.saveError = 'Could not reach the local server.';
} finally {
this.saving = false;
}
},
lock() {
this.authenticated = false;
this.sessionToken = '';
this.username = '';
this.password = '';
this.email = '';
this.originalEmail = '';
this.saveSuccess = false;
this.saveError = '';
},
},
};
</script>

<style scoped>
.pcd-admin-panel {
text-align: right;
}
.pcd-trigger {
cursor: pointer;
opacity: 0.25;
font-size: 0.65em;
user-select: none;
}
.pcd-trigger:hover {
opacity: 0.55;
}
.pcd-panel {
text-align: left;
margin-top: 0.5rem;
}
</style>
2 changes: 2 additions & 0 deletions src/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const Report = () => import('./views/Report.vue');
const TimespiralView = () => import('./views/TimespiralView.vue');
const Dev = () => import('./views/Dev.vue');
const Graph = () => import('./views/Graph.vue');
const PcdAdmin = () => import('./views/PcdAdmin.vue');
const NotFound = () => import('./views/NotFound.vue');

Vue.use(VueRouter);
Expand Down Expand Up @@ -71,6 +72,7 @@ const router = new VueRouter({
{ path: '/search', component: Search },
{ path: '/graph', component: Graph },
{ path: '/dev', component: Dev },
{ path: '/pcd-admin', component: PcdAdmin },
// NOTE: Will break with Vue 3: https://stackoverflow.com/questions/40193634/vue-router-redirect-on-page-not-found-404/64186073#64186073
{
path: '*',
Expand Down
2 changes: 1 addition & 1 deletion src/style/_globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ $textColor: rgb(60, 66, 87);
$backgroundColor: rgb(247, 250, 252);

$lightBorderColor: darken(white, 12%);
$activeHighlightColor: rgb(10, 120, 255);
$activeHighlightColor: #0B9DD0;
26 changes: 26 additions & 0 deletions src/style/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,29 @@ body {
border: 1px solid $lightBorderColor;
border-radius: 5px;
}

// PCD brand color overrides
$pcd-primary: #0B9DD0;
$pcd-secondary: #08A160;

.btn-primary, .badge-primary, .bg-primary {
background-color: $pcd-primary !important;
border-color: $pcd-primary !important;
}

.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
background-color: darken($pcd-primary, 8%) !important;
border-color: darken($pcd-primary, 8%) !important;
}

.btn-success, .badge-success, .bg-success {
background-color: $pcd-secondary !important;
border-color: $pcd-secondary !important;
}

a { color: $pcd-primary; }
a:hover { color: darken($pcd-primary, 10%); }

.aw-navbar {
border-top: 3px solid $pcd-primary;
}
4 changes: 4 additions & 0 deletions src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,18 @@ div
i
| You can change which page opens when you open ActivityWatch (instead of this page) in the #[router-link(to="/settings") settings].

PCDAdminPanel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Organization-specific panel embedded in the community Home page

PCDAdminPanel is now injected into the Home view that every ActivityWatch user sees. This panel is an internal admin tool for a specific organisation (PCD) and has no business living on the welcome screen of an open-source project used by thousands of unrelated users. Even with the low-opacity "PCD" trigger, the component ships its endpoint URLs and auth logic globally. This should be restricted to the /pcd-admin dedicated route rather than embedded here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


</template>

<script lang="ts">
import { mapState } from 'pinia';
import { useServerStore } from '~/stores/server';
import PCDAdminPanel from '~/components/PCDAdminPanel.vue';

export default {
name: 'Home',
components: { PCDAdminPanel },
computed: {
...mapState(useServerStore, ['info']),
},
Expand Down
Loading