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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000
# Display name for the SSO button (default: "SSO")
# OIDC_PROVIDER_NAME=Company SSO

# ─── Optional: Transactional Email ───────────────────────────────────────────
# Reqcore logs emails to the console by default (safe for local dev).
# For production, configure one of the providers below.
# SMTP takes priority over Resend when SMTP_HOST is set.

# Option A: SMTP (recommended for self-hosted / enterprise setups)
# Supports any SMTP server: Postfix, Gmail, Exchange, Mailcow, Mailu, etc.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=reqcore@example.com
# SMTP_PASS=your-smtp-password
# SMTP_FROM="Reqcore <noreply@example.com>"
# SMTP_SECURE=false # true = implicit TLS (port 465), false = STARTTLS (port 587)

# Option B: Resend (free tier: 3,000 emails/month — resend.com)
# RESEND_API_KEY=re_xxxxxxxxxxxx
# RESEND_FROM_EMAIL="Reqcore <noreply@yourcompany.com>"

# ─── Optional: Social Sign-In (Google, GitHub, Microsoft) ────────────────────
# Enable social login buttons on the sign-in and sign-up pages.
# Each provider requires both CLIENT_ID and CLIENT_SECRET to be set.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ test-results/
playwright-report/
blob-report/
playwright/.cache/
.playwright-mcp/


*.code-workspace
Expand Down
37 changes: 31 additions & 6 deletions SELF-HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,37 @@ docker compose up --build -d

By default, Reqcore logs email content to the console (useful for development). For production, configure a transactional email service.

### Using Resend (Recommended)
**Priority:** When `SMTP_HOST` is set, SMTP is used. Otherwise, if `RESEND_API_KEY` is set, Resend is used. If neither is configured, emails are logged to the console.

### Option A: SMTP (recommended for self-hosted setups)

SMTP works with any mail server — Postfix, Gmail, Exchange, Mailcow, Mailu, etc. No external service dependency.

1. Add to your `.env` file:

```bash
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=reqcore@example.com
SMTP_PASS=your-smtp-password
SMTP_FROM="Reqcore <noreply@example.com>"
SMTP_SECURE=false # true for implicit TLS (port 465), false for STARTTLS (port 587)
```

2. Restart: `docker compose up --build -d`

**Common setups:**

| Provider | SMTP_HOST | SMTP_PORT | SMTP_SECURE |
|---|---|---|---|
| Gmail (App Password) | `smtp.gmail.com` | `587` | `false` |
| Outlook / Office 365 | `smtp.office365.com` | `587` | `false` |
| Mailcow / Mailu | your server hostname | `587` | `false` |
| Custom Postfix | your server hostname | `587` or `465` | `false` / `true` |

> For Gmail, generate an [App Password](https://support.google.com/accounts/answer/185833) — your regular Gmail password will not work.

### Option B: Resend

1. Sign up at [resend.com](https://resend.com) (free tier: 3,000 emails/month)
2. Verify your sending domain
Expand All @@ -454,11 +484,6 @@ RESEND_FROM_EMAIL="Reqcore <noreply@yourcompany.com>"

5. Restart: `docker compose up --build -d`

This enables:
- Team member invitation emails
- Interview scheduling notifications
- Candidate communication emails

---

## Security Best Practices
Expand Down
32 changes: 24 additions & 8 deletions app/components/AppTopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ watch(() => route.path, () => {
showGetStartedMenu.value = false
})

// ─────────────────────────────────────────────
// New Job button
// ─────────────────────────────────────────────

const newJobResetSignal = useState('new-job-reset-signal', () => 0)

function handleNewJobClick() {
const newJobPath = localePath('/dashboard/jobs/new')
if (route.path === newJobPath) {
// Already on the page: signal the wizard to reset instead of navigating
newJobResetSignal.value++
} else {
navigateTo(newJobPath)
}
}

// Close user menu on outside click
const userMenuRef = useTemplateRef<HTMLElement>('userMenuRoot')
function onClickOutsideUser(e: MouseEvent) {
Expand Down Expand Up @@ -264,13 +280,13 @@ onUnmounted(() => {
</div>

<!-- New Job button (desktop) -->
<NuxtLink
:to="$localePath('/dashboard/jobs/new')"
class="hidden sm:inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-[13px] font-semibold text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700 hover:shadow-md hover:shadow-brand-600/25 active:bg-brand-800 transition-all duration-200 no-underline"
<button
class="hidden sm:inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-[13px] font-semibold text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700 hover:shadow-md hover:shadow-brand-600/25 active:bg-brand-800 transition-all duration-200 border-0 cursor-pointer"
@click="handleNewJobClick"
>
<Plus class="size-3.5" />
New Job
</NuxtLink>
</button>

<!-- Org Switcher -->
<div class="hidden lg:block ml-1">
Expand Down Expand Up @@ -494,13 +510,13 @@ onUnmounted(() => {
</span>
</NuxtLink>

<NuxtLink
:to="$localePath('/dashboard/jobs/new')"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors no-underline sm:hidden mt-1"
<button
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors sm:hidden mt-1 border-0 cursor-pointer w-full"
@click="handleNewJobClick(); showMobileMenu = false"
>
<Plus class="size-4" />
New Job
</NuxtLink>
</button>

<!-- Get Started CTA (demo mode, mobile) -->
<template v-if="isDemo">
Expand Down
3 changes: 2 additions & 1 deletion app/components/ApplyCandidateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const { data: candidateData, status: searchStatus } = useFetch('/api/candidates'

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

// Apply candidate
const isApplying = ref(false)
Expand Down Expand Up @@ -117,7 +118,7 @@ async function applyCandidate(candidateId: string) {
>
<div class="min-w-0">
<p class="text-sm font-medium text-surface-900 dark:text-surface-100 truncate">
{{ c.firstName }} {{ c.lastName }}
{{ formatCandidateName(c) }}
</p>
<p class="text-xs text-surface-400 truncate">{{ c.email }}</p>
</div>
Expand Down
5 changes: 3 additions & 2 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const emit = defineEmits<{
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const toast = useToast()
const { track } = useTrack()
const { formatCandidateName } = useOrgSettings()

// Detect if the job sub-nav bar is visible (adds 40px / 2.5rem)
const route = useRoute()
Expand Down Expand Up @@ -467,7 +468,7 @@ function formatInterviewDate(dateStr: string) {
</div>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-50 truncate">
{{ application.candidate.firstName }} {{ application.candidate.lastName }}
{{ formatCandidateName(application.candidate) }}
</h2>
<div class="flex items-center gap-3 text-sm text-surface-500 dark:text-surface-400">
<a
Expand Down Expand Up @@ -617,7 +618,7 @@ function formatInterviewDate(dateStr: string) {
<div>
<dt class="text-xs font-medium text-surface-400 dark:text-surface-500 mb-1">Name</dt>
<dd class="text-surface-800 dark:text-surface-200 font-medium">
{{ application.candidate.firstName }} {{ application.candidate.lastName }}
{{ formatCandidateName(application.candidate) }}
</dd>
</div>
<div>
Expand Down
3 changes: 2 additions & 1 deletion app/components/InterviewEmailModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const emit = defineEmits<{
}>()

const { templates, createTemplate, deleteTemplate, sendInvitation } = useEmailTemplates()
const { formatPersonName } = useOrgSettings()

Comment on lines +19 to 20
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 | 🟡 Minor

candidateName preview variable should use the same localized formatter.

You now format the header name, but previewVariables.candidateName (Line 57) still uses raw first/last, so previewed template content can be inconsistent.

Suggested fix
 const previewVariables: Record<string, string> = {
-  candidateName: `${props.interview.candidateFirstName} ${props.interview.candidateLastName}`,
+  candidateName: formatPersonName(props.interview.candidateFirstName, props.interview.candidateLastName),
   candidateFirstName: props.interview.candidateFirstName,
   candidateLastName: props.interview.candidateLastName,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/InterviewEmailModal.vue` around lines 19 - 20, Preview
variable previewVariables.candidateName is constructed from raw first/last names
while the header uses the localized formatter; change the code that sets
previewVariables.candidateName to call formatPersonName(candidate) (or
formatPersonName({ firstName, lastName })) instead of concatenating
firstName/lastName so both the header and template preview use the same
localized formatting; update any place that builds previewVariables for
InterviewEmailModal.vue to use the formatPersonName helper.

// ─── System templates (from shared utility — auto-imported) ────

Expand Down Expand Up @@ -171,7 +172,7 @@ const canSend = computed(() => {
Send Interview Invitation
</h2>
<p class="text-xs text-surface-500 dark:text-surface-400">
to {{ interview.candidateFirstName }} {{ interview.candidateLastName }} · {{ interview.candidateEmail }}
to {{ formatPersonName(interview.candidateFirstName, interview.candidateLastName) }} · {{ interview.candidateEmail }}
</p>
</div>
</div>
Expand Down
6 changes: 4 additions & 2 deletions app/components/PipelineCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const transitionClasses: Record<string, string> = {
hired: 'text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900',
rejected: 'text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950',
}

const { formatPersonName, formatDateTime } = useOrgSettings()
</script>

<template>
Expand All @@ -42,7 +44,7 @@ const transitionClasses: Record<string, string> = {
class="block mb-2 group"
>
<h4 class="text-sm font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors truncate">
{{ candidateFirstName }} {{ candidateLastName }}
{{ formatPersonName(candidateFirstName, candidateLastName) }}
</h4>
<div class="flex items-center gap-2 text-xs text-surface-400 mt-0.5">
<a
Expand All @@ -60,7 +62,7 @@ const transitionClasses: Record<string, string> = {
<div class="flex items-center justify-between text-xs text-surface-400">
<span class="inline-flex items-center gap-1">
<Calendar class="size-3" />
{{ new Date(createdAt).toLocaleDateString() }}
{{ formatDateTime(createdAt) }}
</span>
<span v-if="score != null" class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset"
:class="score >= 75
Expand Down
8 changes: 7 additions & 1 deletion app/components/SettingsMobileNav.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {
Building2, Users, UserCircle, ChevronLeft, Plug, Brain, ShieldCheck,
Building2, Users, UserCircle, ChevronLeft, Plug, Brain, ShieldCheck, Globe,
} from 'lucide-vue-next'

const route = useRoute()
Expand All @@ -13,6 +13,12 @@ const settingsNav = [
icon: Building2,
exact: true,
},
{
label: 'Localization',
to: '/dashboard/settings/localization',
icon: Globe,
exact: true,
},
{
label: 'Members',
to: '/dashboard/settings/members',
Expand Down
9 changes: 8 additions & 1 deletion app/components/SettingsSidebar.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {
Building2, Users, UserCircle, ChevronLeft, Settings, Plug, Brain, ShieldCheck,
Building2, Users, UserCircle, ChevronLeft, Settings, Plug, Brain, ShieldCheck, Globe,
} from 'lucide-vue-next'

const route = useRoute()
Expand All @@ -14,6 +14,13 @@ const settingsNav = [
icon: Building2,
exact: true,
},
{
label: 'Localization',
description: 'Names & date formats',
to: '/dashboard/settings/localization',
icon: Globe,
exact: true,
},
{
label: 'Members',
description: 'Team & invitations',
Expand Down
3 changes: 3 additions & 0 deletions app/composables/useCandidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ export function useCandidate(id: MaybeRefOrGetter<string>) {
async function updateCandidate(payload: Partial<{
firstName: string
lastName: string
displayName: string | null
email: string
phone: string | null
gender: 'male' | 'female' | 'other' | 'prefer_not_to_say' | null
dateOfBirth: string | null
}>) {
try {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
Expand Down
9 changes: 9 additions & 0 deletions app/composables/useCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
*/
export function useCandidates(options?: {
search?: Ref<string | undefined> | string
gender?: Ref<string | undefined> | string
dobFrom?: Ref<string | undefined> | string
dobTo?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.search) && { search: toValue(options?.search) }),
...(toValue(options?.gender) && { gender: toValue(options?.gender) }),
...(toValue(options?.dobFrom) && { dobFrom: toValue(options?.dobFrom) }),
...(toValue(options?.dobTo) && { dobTo: toValue(options?.dobTo) }),
}))

const { data, status: fetchStatus, error, refresh } = useFetch('/api/candidates', {
Expand All @@ -27,8 +33,11 @@ export function useCandidates(options?: {
async function createCandidate(payload: {
firstName: string
lastName: string
displayName?: string
email: string
phone?: string
gender?: 'male' | 'female' | 'other' | 'prefer_not_to_say'
dateOfBirth?: string
}) {
try {
const created = await $fetch('/api/candidates', {
Expand Down
13 changes: 11 additions & 2 deletions app/composables/useJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,21 @@ export function useJob(id: MaybeRefOrGetter<string>) {
/** Update job fields (partial) and refresh both detail and list caches */
async function updateJob(payload: Partial<{
title: string
description: string
location: string
description: string | null
location: string | null
type: 'full_time' | 'part_time' | 'contract' | 'internship'
status: 'draft' | 'open' | 'closed' | 'archived'
salaryMin: number | null
salaryMax: number | null
salaryCurrency: string | null
salaryUnit: 'YEAR' | 'MONTH' | 'HOUR' | null
salaryNegotiable: boolean
remoteStatus: 'remote' | 'hybrid' | 'onsite' | null
validThrough: Date | null
requireResume: boolean
requireCoverLetter: boolean
autoScoreOnApply: boolean
experienceLevel: 'junior' | 'mid' | 'senior' | 'lead' | null
}>) {
try {
const updated = await $fetch(`/api/jobs/${jobId.value}`, {
Expand Down
1 change: 1 addition & 0 deletions app/composables/useJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function useJobs(options?: {
description?: string
location?: string
type?: 'full_time' | 'part_time' | 'contract' | 'internship'
experienceLevel?: 'junior' | 'mid' | 'senior' | 'lead'
remoteStatus?: 'remote' | 'hybrid' | 'onsite'
requireResume?: boolean
requireCoverLetter?: boolean
Expand Down
Loading
Loading