Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ S3_FORCE_PATH_STYLE=true
# Production: https://applirank.com
NUXT_PUBLIC_SITE_URL=http://localhost:3000

# Public URL used by preview upsell modal "Upgrade to hosted plan" button
# NUXT_PUBLIC_HOSTED_PLAN_URL=https://applirank.com

# ─── Optional: Demo Mode ────────────────────
# Set to an org slug to make that org read-only (blocks mutations)
# DEMO_ORG_SLUG=demo
# Default seeded slug: applirank-demo
# DEMO_ORG_SLUG=applirank-demo

# ─── Optional: Trusted Proxy ────────────────
# IP address of the reverse proxy (Railway/Cloudflare). When set,
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,36 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add

---

## 2026-02-22

### Fixed

- **Railway PR seed execution** — removed hard `.env` dependency from `db:seed`; seeding now works with platform-injected env vars and still supports local `.env` loading in `seed.ts`

### Changed

- **Unified Railway seeding path** — Railway predeploy now runs `db:seed` (same script as standard demo data), removing PR-specific seed divergence between preview and production-like environments
- **Preview demo defaults aligned** — runtime preview fallbacks now target `applirank-demo` and `demo@applirank.com` to match `server/scripts/seed.ts`

### Removed

- **PR-only seed script** — removed `server/scripts/seed-pr.ts` and the `db:seed:pr` npm script

---

## 2026-02-21

### Fixed

- **Dependency security remediation** — resolved all `npm audit --audit-level=high` findings by upgrading `@aws-sdk/client-s3` (pulling patched `@aws-sdk/xml-builder`) and regenerating lockfile resolution
- **Transitive vulnerability pinning** — added npm `overrides` for `fast-xml-parser`, `minimatch`, `tar`, and `readdir-glob` to keep vulnerable transitive ranges out of the install graph
- **Demo write-protection enforcement** — hardened server demo guard so `POST`/`PATCH`/`PUT`/`DELETE` requests are consistently blocked for the configured demo organization and no longer silently fail open when demo org lookup fails
- **Dashboard preview UX** — write attempts in preview mode now trigger a dedicated upsell modal instead of only inline/API errors, while keeping action buttons visible

### Changed

- **Lockfile hygiene** — refreshed dependency graph with `npm install` + `npm dedupe` to remove stale vulnerable transitive entries
- **Demo env guidance** — `.env.example` demo slug example now matches seeded demo organization slug (`applirank-demo`) to reduce configuration drift

---

Expand Down
3 changes: 3 additions & 0 deletions app/components/ApplyCandidateModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Search, X, UserPlus } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
jobId: string
Expand Down Expand Up @@ -32,6 +33,7 @@ const { data: candidateData, status: searchStatus } = useFetch('/api/candidates'
})

const candidates = computed(() => candidateData.value?.data ?? [])
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

// Apply candidate
const isApplying = ref(false)
Expand All @@ -47,6 +49,7 @@ async function applyCandidate(candidateId: string) {
})
emit('created')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
applyError.value = err.data?.statusMessage ?? 'Failed to apply candidate'
} finally {
isApplying.value = false
Expand Down
3 changes: 3 additions & 0 deletions app/components/ApplyToJobModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { X, Briefcase } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
candidateId: string
Expand All @@ -18,6 +19,7 @@ const { data: jobData, status: jobFetchStatus } = useFetch('/api/jobs', {
})

const jobs = computed(() => jobData.value?.data ?? [])
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

// Apply to job
const isApplying = ref(false)
Expand All @@ -33,6 +35,7 @@ async function applyToJob(jobId: string) {
})
emit('created')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
applyError.value = err.data?.statusMessage ?? 'Failed to apply to job'
} finally {
isApplying.value = false
Expand Down
6 changes: 6 additions & 0 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
ArrowLeft, AlertTriangle,
} from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
applicationId: string
Expand All @@ -15,6 +16,8 @@ const emit = defineEmits<{
(e: 'updated'): void
}>()

const { handlePreviewReadOnlyError } = usePreviewReadOnly()

// ─────────────────────────────────────────────
// Tabs
// ─────────────────────────────────────────────
Expand Down Expand Up @@ -114,6 +117,7 @@ async function handleTransition(newStatus: string) {
await refresh()
emit('updated')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to update status')
} finally {
isTransitioning.value = false
Expand Down Expand Up @@ -144,6 +148,7 @@ async function saveNotes() {
emit('updated')
isEditingNotes.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to save notes')
} finally {
isSavingNotes.value = false
Expand Down Expand Up @@ -247,6 +252,7 @@ async function handleDeleteDoc(docId: string) {
await refreshCandidate()
showDocDeleteConfirm.value = null
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to delete document')
} finally {
isDeletingDoc.value = false
Expand Down
69 changes: 69 additions & 0 deletions app/components/PreviewUpsellModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'

const emit = defineEmits<{
(e: 'close'): void
}>()

const { message } = usePreviewReadOnly()
const config = useRuntimeConfig()

function closeModal() {
emit('close')
}
</script>

<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />

<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>

<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>

<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>

<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>

<div class="flex flex-wrap items-center gap-2">
<a
:href="config.public.hostedPlanUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Upgrade to hosted plan
</a>

<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Self-host on GitHub
</a>
</div>
</div>
</div>
</div>
Comment on lines +18 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Non-navigable modal — missing ARIA roles, keyboard handling, and focus management

The modal is inaccessible to keyboard and screen-reader users. The following gaps all need addressing:

Gap Fix
No role="dialog" / aria-modal="true" on the container Add to the inner div (line 20)
No aria-labelledby linking the title Give <h3> an id, reference it
No Escape key listener @keydown.esc.window="closeModal"
No focus trap Tab cycles outside the modal
Close button has no accessible name Add aria-label="Close dialog"
Focus not moved into modal on open, not restored on close Programmatic focus management via templateRef + watch/onMounted

WAI-ARIA Authoring Practices require: closing on ESC, toggling ARIA attributes, and trapping/restoring focus. A modal dialog should have both role="dialog" and aria-modal="true" so screen readers treat it as a modal.

♿ Proposed minimal fix
+<script setup lang="ts">
 import { Crown, X, Rocket } from 'lucide-vue-next'
+import { onMounted, useTemplateRef } from 'vue'

 const emit = defineEmits<{
   (e: 'close'): void
 }>()

 const { message } = usePreviewReadOnly()
+const dialogRef = useTemplateRef<HTMLDivElement>('dialog')

 function closeModal() {
   emit('close')
 }
+
+function onKeydown(e: KeyboardEvent) {
+  if (e.key === 'Escape') closeModal()
+}
+
+onMounted(() => {
+  dialogRef.value?.focus()
+})
+</script>

 <template>
   <Teleport to="body">
-    <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
+    <div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
       <div class="absolute inset-0 bg-black/50" `@click`="closeModal" />

-      <div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
+      <div
+        ref="dialog"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="upsell-title"
+        tabindex="-1"
+        class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
+      >
         <div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
           <div class="flex items-center gap-2">
             <Crown class="size-5 text-brand-600 dark:text-brand-400" />
-            <h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
+            <h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
           </div>

           <button
+            aria-label="Close dialog"
             class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
             `@click`="closeModal"
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />
<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'
import { onMounted, useTemplateRef } from 'vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
const { message } = usePreviewReadOnly()
const dialogRef = useTemplateRef<HTMLDivElement>('dialog')
function closeModal() {
emit('close')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeModal()
}
onMounted(() => {
dialogRef.value?.focus()
})
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
<div class="absolute inset-0 bg-black/50" `@click`="closeModal" />
<div
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="upsell-title"
tabindex="-1"
class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
>
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
aria-label="Close dialog"
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
`@click`="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
</Teleport>
</template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/PreviewUpsellModal.vue` around lines 17 - 64, The modal lacks
ARIA roles, keyboard handling, and focus management: add role="dialog" and
aria-modal="true" to the inner modal container element, give the <h3> a unique
id and reference it via aria-labelledby on that container, add
`@keydown.esc.window`="closeModal" to capture Escape, add aria-label="Close
dialog" to the close <button>, and implement focus management by adding a
template ref (e.g., modalRef) and code that, when the modal opens, saves
document.activeElement, moves focus into the first focusable element inside the
modal, traps Tab/Shift+Tab inside the modal, and restores focus on close using
the existing closeModal method; you can implement the trap with a small utility
in setup() or use a11y helper lib, and wire the watch/onMounted hooks to run
this behavior when the modal becomes visible.

</Teleport>
</template>
20 changes: 13 additions & 7 deletions app/composables/useApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
* Wraps `useFetch('/api/applications/:id')` with a reactive key.
*/
export function useApplication(id: MaybeRefOrGetter<string>) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const applicationId = computed(() => toValue(id))

const { data: application, status, error, refresh } = useFetch(
Expand All @@ -21,13 +22,18 @@ export function useApplication(id: MaybeRefOrGetter<string>) {
notes: string | null
score: number | null
}>) {
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('applications')
return updated
try {
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('applications')
return updated
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

return { application, status, error, refresh, updateApplication }
Expand Down
20 changes: 14 additions & 6 deletions app/composables/useApplications.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for managing the applications list with filtering, pagination, and mutations.
Expand All @@ -9,6 +10,8 @@ export function useApplications(options?: {
candidateId?: Ref<string | undefined> | string
status?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.jobId) && { jobId: toValue(options?.jobId) }),
...(toValue(options?.candidateId) && { candidateId: toValue(options?.candidateId) }),
Expand All @@ -30,12 +33,17 @@ export function useApplications(options?: {
jobId: string
notes?: string
}) {
const created = await $fetch('/api/applications', {
method: 'POST',
body: payload,
})
await refresh()
return created
try {
const created = await $fetch('/api/applications', {
method: 'POST',
body: payload,
})
await refresh()
return created
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

return {
Expand Down
28 changes: 20 additions & 8 deletions app/composables/useCandidate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { MaybeRefOrGetter } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for a single candidate detail with update and delete mutations.
* Wraps `useFetch('/api/candidates/:id')` with a reactive key.
*/
export function useCandidate(id: MaybeRefOrGetter<string>) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const candidateId = computed(() => toValue(id))

const { data: candidate, status, error, refresh } = useFetch(
Expand All @@ -22,18 +24,28 @@ export function useCandidate(id: MaybeRefOrGetter<string>) {
email: string
phone: string | null
}>) {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('candidates')
return updated
try {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('candidates')
return updated
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

/** Delete this candidate and navigate back to the list */
async function deleteCandidate() {
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
try {
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
await refreshNuxtData('candidates')
await navigateTo('/dashboard/candidates')
}
Expand Down
27 changes: 20 additions & 7 deletions app/composables/useCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for managing the candidates list with search, pagination, and mutations.
Expand All @@ -7,6 +8,8 @@ import type { Ref } from 'vue'
export function useCandidates(options?: {
search?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.search) && { search: toValue(options?.search) }),
}))
Expand All @@ -27,17 +30,27 @@ export function useCandidates(options?: {
email: string
phone?: string
}) {
const created = await $fetch('/api/candidates', {
method: 'POST',
body: payload,
})
await refresh()
return created
try {
const created = await $fetch('/api/candidates', {
method: 'POST',
body: payload,
})
await refresh()
return created
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

/** Delete a candidate by ID and refresh the list */
async function deleteCandidate(id: string) {
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
try {
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
await refresh()
}

Expand Down
Loading