falak-app-duo 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/bin/falak-app-duo +123 -0
- package/package.json +34 -0
- package/templates/default/.env.example +23 -0
- package/templates/default/index.html +12 -0
- package/templates/default/package.json +29 -0
- package/templates/default/postcss.config.js +7 -0
- package/templates/default/src/App.vue +36 -0
- package/templates/default/src/assets/main.css +8 -0
- package/templates/default/src/i18n/index.js +25 -0
- package/templates/default/src/i18n/locales/ar.json +18 -0
- package/templates/default/src/i18n/locales/en.json +18 -0
- package/templates/default/src/main.js +14 -0
- package/templates/default/src/router/index.js +21 -0
- package/templates/default/src/services/ably.js +66 -0
- package/templates/default/src/services/api.js +49 -0
- package/templates/default/src/services/brevo.js +35 -0
- package/templates/default/src/stores/auth.js +53 -0
- package/templates/default/src/utils/crypto.js +30 -0
- package/templates/default/src/views/Home.vue +16 -0
- package/templates/default/src/views/Login.vue +49 -0
- package/templates/default/tailwind.config.js +13 -0
- package/templates/default/vite.config.js +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# falak-app-duo
|
|
2
|
+
|
|
3
|
+
Vue 3 boilerplate scaffolder. Firebase-free, with Groq moved fully to the
|
|
4
|
+
backend and Paymob dropped for good. "Duo" = the two remaining bundled
|
|
5
|
+
integrations on the frontend side: **Ably** (realtime) and **Brevo**
|
|
6
|
+
(email/SMS, proxied through Laravel).
|
|
7
|
+
|
|
8
|
+
| | Status |
|
|
9
|
+
|---|---|
|
|
10
|
+
| Firebase | Removed |
|
|
11
|
+
| Groq (AI) | Removed from frontend — backend-only now, no client code here |
|
|
12
|
+
| Paymob (payments) | Removed for good |
|
|
13
|
+
| Ably (realtime) | **New** — via Laravel Echo, authorized through the same API-key + user-token headers as the rest of the app |
|
|
14
|
+
| Brevo (email/SMS) | Kept — calls go through Laravel, key never ships to the browser |
|
|
15
|
+
| Laravel API auth | `X-API-Key` (app) + `Authorization: Bearer <token>` (user) |
|
|
16
|
+
| AES-256 | Kept — encrypts the cached user token in localStorage |
|
|
17
|
+
| Arabic/RTL | Kept — baked in via vue-i18n + postcss-rtlcss |
|
|
18
|
+
|
|
19
|
+
## Auth model
|
|
20
|
+
|
|
21
|
+
Every request from `src/services/api.js` carries two headers:
|
|
22
|
+
|
|
23
|
+
- `X-API-Key` — identifies this client app, set once per environment in `.env`.
|
|
24
|
+
- `Authorization: Bearer <user token>` — identifies the logged-in user, issued
|
|
25
|
+
by your Laravel `/auth/login` endpoint and stored AES-256 encrypted in
|
|
26
|
+
`localStorage` (see `src/utils/crypto.js` and `src/stores/auth.js`).
|
|
27
|
+
|
|
28
|
+
## Ably (realtime)
|
|
29
|
+
|
|
30
|
+
`src/services/ably.js` wraps Laravel Echo with the `ably` broadcaster. Private
|
|
31
|
+
and presence channels are authorized via a custom `authorizer` that routes
|
|
32
|
+
through the shared `api` axios client (`POST /broadcasting/auth`) instead of
|
|
33
|
+
a bare fetch — so the same `X-API-Key` + bearer token gate realtime auth too.
|
|
34
|
+
|
|
35
|
+
Backend needs:
|
|
36
|
+
```php
|
|
37
|
+
// config/broadcasting.php
|
|
38
|
+
'default' => 'ably',
|
|
39
|
+
'connections' => ['ably' => ['driver' => 'ably', 'key' => env('ABLY_KEY')]],
|
|
40
|
+
```
|
|
41
|
+
plus `BroadcastServiceProvider` registered so `/broadcasting/auth` exists.
|
|
42
|
+
Only the **public** half of your Ably key goes in the frontend `.env`
|
|
43
|
+
(`VITE_ABLY_PUBLIC_KEY`) — the secret half stays server-side.
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import { listenPrivate, joinPresence } from "@/services/ably";
|
|
47
|
+
|
|
48
|
+
listenPrivate(`App.Models.User.${userId}`, ".OrderUpdated", (e) => { ... });
|
|
49
|
+
joinPresence("chat-room-1", { here: (users) => { ... } });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Brevo (email/SMS)
|
|
53
|
+
|
|
54
|
+
`src/services/brevo.js` only calls your own Laravel routes — Brevo's API key
|
|
55
|
+
is a backend secret and never ships to the browser.
|
|
56
|
+
|
|
57
|
+
## Groq
|
|
58
|
+
|
|
59
|
+
No frontend code at all now — the backend calls Groq directly and exposes
|
|
60
|
+
whatever endpoint(s) your app needs (e.g. `POST /api/ai/chat`). Call that
|
|
61
|
+
endpoint through the shared `api` client wherever you need it; there's
|
|
62
|
+
nothing to scaffold on the client side for it.
|
|
63
|
+
|
|
64
|
+
## Backend routes this frontend expects
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
POST /api/auth/login -> { token, user }
|
|
68
|
+
POST /api/auth/logout
|
|
69
|
+
GET /api/auth/me -> user
|
|
70
|
+
POST /api/broadcasting/auth (Ably/Echo channel authorization)
|
|
71
|
+
POST /api/brevo/email | /api/brevo/sms | /api/brevo/contacts
|
|
72
|
+
```
|
|
73
|
+
plus an `X-API-Key` middleware gating all of the above, and Sanctum (or
|
|
74
|
+
similar) validating the bearer token for user-scoped routes.
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
node bin/falak-app-duo my-project # interactive prompts
|
|
80
|
+
node bin/falak-app-duo my-project --yes # defaults: Ably + Brevo, localhost API
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Once published to npm you'd run it as `npx falak-app-duo my-project`.
|
|
84
|
+
|
|
85
|
+
The CLI:
|
|
86
|
+
1. Copies `templates/default` into `my-project/`.
|
|
87
|
+
2. Strips out `ably.js` and/or `brevo.js` if you didn't select them.
|
|
88
|
+
3. Generates a real `.env` from `.env.example` with a fresh random AES-256
|
|
89
|
+
key + IV, and your chosen API base URL.
|
|
90
|
+
|
|
91
|
+
Then:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
cd my-project
|
|
95
|
+
npm install
|
|
96
|
+
npm run dev
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Fill in `VITE_API_KEY` and `VITE_ABLY_PUBLIC_KEY` in `.env` before running
|
|
100
|
+
against a real backend.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import prompts from "prompts";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const TEMPLATE_DIR = path.join(__dirname, "..", "templates", "default");
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name("falak-app")
|
|
16
|
+
.description("Scaffold a Vue 3 app with Ably, Brevo, AES-256 and a Laravel API client baked in.")
|
|
17
|
+
.argument("[project-name]", "Directory to create the project in")
|
|
18
|
+
.option("-y, --yes", "Skip prompts and use defaults (all services enabled)")
|
|
19
|
+
.parse(process.argv);
|
|
20
|
+
|
|
21
|
+
const [argProjectName] = program.args;
|
|
22
|
+
const opts = program.opts();
|
|
23
|
+
|
|
24
|
+
function randomHex(bytes) {
|
|
25
|
+
return crypto.randomBytes(bytes).toString("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
console.log(chalk.cyanBright("\n falak-app \u2014 Vue 3 scaffolder\n"));
|
|
30
|
+
|
|
31
|
+
const answers = opts.yes
|
|
32
|
+
? {
|
|
33
|
+
projectName: argProjectName || "falak-app-duo",
|
|
34
|
+
services: ["ably", "brevo"],
|
|
35
|
+
apiBaseUrl: "http://localhost:8000/api",
|
|
36
|
+
}
|
|
37
|
+
: await prompts(
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
type: argProjectName ? null : "text",
|
|
41
|
+
name: "projectName",
|
|
42
|
+
message: "Project name / folder:",
|
|
43
|
+
initial: "falak-app-duo",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: "multiselect",
|
|
47
|
+
name: "services",
|
|
48
|
+
message: "Which services do you want wired up?",
|
|
49
|
+
choices: [
|
|
50
|
+
{ title: "Ably (realtime, via Laravel Echo)", value: "ably", selected: true },
|
|
51
|
+
{ title: "Brevo (email/SMS, via backend)", value: "brevo", selected: true },
|
|
52
|
+
],
|
|
53
|
+
hint: "Space to toggle, Enter to confirm",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: "text",
|
|
57
|
+
name: "apiBaseUrl",
|
|
58
|
+
message: "Laravel API base URL:",
|
|
59
|
+
initial: "http://localhost:8000/api",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
{ onCancel: () => process.exit(1) }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const projectName = argProjectName || answers.projectName;
|
|
66
|
+
const services = answers.services || [];
|
|
67
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
68
|
+
|
|
69
|
+
if (await fs.pathExists(targetDir)) {
|
|
70
|
+
const files = await fs.readdir(targetDir);
|
|
71
|
+
if (files.length > 0) {
|
|
72
|
+
console.log(chalk.red(`\n Directory "${projectName}" already exists and is not empty.\n`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(chalk.gray(`\n Scaffolding into ./${projectName} ...\n`));
|
|
78
|
+
await fs.copy(TEMPLATE_DIR, targetDir);
|
|
79
|
+
|
|
80
|
+
// Rename the app's package.json placeholder and set the real project name
|
|
81
|
+
const pkgPath = path.join(targetDir, "package.json");
|
|
82
|
+
const pkg = await fs.readJson(pkgPath);
|
|
83
|
+
pkg.name = projectName
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
86
|
+
.replace(/-+/g, "-");
|
|
87
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
88
|
+
|
|
89
|
+
// Remove service files the user didn't select
|
|
90
|
+
const serviceFiles = {
|
|
91
|
+
ably: "src/services/ably.js",
|
|
92
|
+
brevo: "src/services/brevo.js",
|
|
93
|
+
};
|
|
94
|
+
for (const [key, relPath] of Object.entries(serviceFiles)) {
|
|
95
|
+
if (!services.includes(key)) {
|
|
96
|
+
await fs.remove(path.join(targetDir, relPath));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Generate a real .env from .env.example with a fresh encryption key
|
|
101
|
+
const envExamplePath = path.join(targetDir, ".env.example");
|
|
102
|
+
let envContent = await fs.readFile(envExamplePath, "utf-8");
|
|
103
|
+
envContent = envContent
|
|
104
|
+
.replace("__API_BASE_URL__", answers.apiBaseUrl || "http://localhost:8000/api")
|
|
105
|
+
.replace("__AES_KEY__", randomHex(32)) // 256-bit key, hex-encoded
|
|
106
|
+
.replace("__AES_IV__", randomHex(16)); // 128-bit IV, hex-encoded
|
|
107
|
+
await fs.writeFile(path.join(targetDir, ".env"), envContent);
|
|
108
|
+
|
|
109
|
+
console.log(chalk.green(" Done! Next steps:\n"));
|
|
110
|
+
console.log(chalk.white(` cd ${projectName}`));
|
|
111
|
+
console.log(chalk.white(" npm install"));
|
|
112
|
+
console.log(chalk.white(" npm run dev\n"));
|
|
113
|
+
console.log(
|
|
114
|
+
chalk.gray(
|
|
115
|
+
" Fill in VITE_API_KEY, VITE_ABLY_PUBLIC_KEY, Brevo backend routes, and review .env before shipping.\n"
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main().catch((err) => {
|
|
121
|
+
console.error(chalk.red(err));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "falak-app-duo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vue 3 boilerplate scaffolder — Ably (realtime), Brevo, AES-256, Arabic/RTL, and a Laravel API client (API key + user token auth). No Firebase, no Groq/Paymob on the frontend (Groq is backend-only; Paymob dropped).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"falak-app-duo": "bin/falak-app-duo"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"templates"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"chalk": "^5.3.0",
|
|
18
|
+
"commander": "^12.1.0",
|
|
19
|
+
"fs-extra": "^11.2.0",
|
|
20
|
+
"prompts": "^2.4.2"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"vue3",
|
|
24
|
+
"boilerplate",
|
|
25
|
+
"scaffolder",
|
|
26
|
+
"arabic",
|
|
27
|
+
"rtl",
|
|
28
|
+
"laravel",
|
|
29
|
+
"brevo",
|
|
30
|
+
"ably",
|
|
31
|
+
"realtime"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# --- Laravel API (Falak Bridge or your own backend) ---
|
|
2
|
+
# Base URL of your Laravel API
|
|
3
|
+
VITE_API_BASE_URL=__API_BASE_URL__
|
|
4
|
+
# Static API key issued to this client app (sent as X-API-Key on every request)
|
|
5
|
+
VITE_API_KEY=replace-with-your-app-api-key
|
|
6
|
+
|
|
7
|
+
# --- Ably (realtime, via Laravel Echo) ---
|
|
8
|
+
# Public/client half of your Ably key only. Private channels are authorized
|
|
9
|
+
# through your Laravel /broadcasting/auth route (see src/services/ably.js) —
|
|
10
|
+
# the secret half of the Ably key stays server-side.
|
|
11
|
+
VITE_ABLY_PUBLIC_KEY=
|
|
12
|
+
|
|
13
|
+
# --- Brevo (email / SMS) ---
|
|
14
|
+
# Brevo's API key must never live in the frontend. Sending is done by the
|
|
15
|
+
# Laravel backend; the client only calls your own /brevo/* endpoints.
|
|
16
|
+
# Nothing to configure here — this exists for documentation purposes.
|
|
17
|
+
|
|
18
|
+
# --- AI (Groq) ---
|
|
19
|
+
# Handled entirely by the backend now — no client-side key or service here.
|
|
20
|
+
|
|
21
|
+
# --- AES-256 client-side encryption (e.g. for local storage of sensitive fields) ---
|
|
22
|
+
VITE_AES_KEY=__AES_KEY__
|
|
23
|
+
VITE_AES_IV=__AES_IV__
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ar" dir="rtl">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Falak App</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.js"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "falak-app-duo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"ably": "^2.5.0",
|
|
13
|
+
"axios": "^1.7.7",
|
|
14
|
+
"crypto-js": "^4.2.0",
|
|
15
|
+
"laravel-echo": "^1.16.1",
|
|
16
|
+
"pinia": "^2.2.2",
|
|
17
|
+
"vue": "^3.5.0",
|
|
18
|
+
"vue-i18n": "^9.14.0",
|
|
19
|
+
"vue-router": "^4.4.5"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@vitejs/plugin-vue": "^5.1.4",
|
|
23
|
+
"autoprefixer": "^10.4.20",
|
|
24
|
+
"postcss": "^8.4.47",
|
|
25
|
+
"postcss-rtlcss": "^4.0.9",
|
|
26
|
+
"tailwindcss": "^3.4.13",
|
|
27
|
+
"vite": "^5.4.8"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useI18n } from "vue-i18n";
|
|
3
|
+
import { setLocale } from "@/i18n";
|
|
4
|
+
import { useAuthStore } from "@/stores/auth";
|
|
5
|
+
import { RouterLink, RouterView } from "vue-router";
|
|
6
|
+
|
|
7
|
+
const { t, locale } = useI18n();
|
|
8
|
+
const auth = useAuthStore();
|
|
9
|
+
|
|
10
|
+
function toggleLocale() {
|
|
11
|
+
setLocale(locale.value === "ar" ? "en" : "ar");
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div class="min-h-screen bg-gray-50 text-gray-900">
|
|
17
|
+
<nav class="flex items-center justify-between px-6 py-4 bg-white shadow-sm">
|
|
18
|
+
<span class="font-bold text-lg">{{ t("app.name") }}</span>
|
|
19
|
+
<div class="flex items-center gap-4">
|
|
20
|
+
<RouterLink to="/" class="hover:underline">{{ t("nav.home") }}</RouterLink>
|
|
21
|
+
<RouterLink v-if="!auth.userToken" to="/login" class="hover:underline">{{
|
|
22
|
+
t("nav.login")
|
|
23
|
+
}}</RouterLink>
|
|
24
|
+
<button v-else @click="auth.logout()" class="text-sm text-red-600 hover:underline">
|
|
25
|
+
{{ t("nav.login") === "Login" ? "Logout" : "خروج" }}
|
|
26
|
+
</button>
|
|
27
|
+
<button @click="toggleLocale" class="text-sm px-2 py-1 border rounded">
|
|
28
|
+
{{ locale === "ar" ? "EN" : "AR" }}
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</nav>
|
|
32
|
+
<main class="p-6">
|
|
33
|
+
<RouterView />
|
|
34
|
+
</main>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createI18n } from "vue-i18n";
|
|
2
|
+
import ar from "./locales/ar.json";
|
|
3
|
+
import en from "./locales/en.json";
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = "falak_locale";
|
|
6
|
+
const savedLocale = localStorage.getItem(STORAGE_KEY) || "ar";
|
|
7
|
+
|
|
8
|
+
const i18n = createI18n({
|
|
9
|
+
legacy: false,
|
|
10
|
+
locale: savedLocale,
|
|
11
|
+
fallbackLocale: "en",
|
|
12
|
+
messages: { ar, en },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function setLocale(locale) {
|
|
16
|
+
i18n.global.locale.value = locale;
|
|
17
|
+
localStorage.setItem(STORAGE_KEY, locale);
|
|
18
|
+
document.documentElement.lang = locale;
|
|
19
|
+
document.documentElement.dir = locale === "ar" ? "rtl" : "ltr";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Apply on load
|
|
23
|
+
setLocale(savedLocale);
|
|
24
|
+
|
|
25
|
+
export default i18n;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app": {
|
|
3
|
+
"name": "فلك"
|
|
4
|
+
},
|
|
5
|
+
"nav": {
|
|
6
|
+
"home": "الرئيسية",
|
|
7
|
+
"login": "تسجيل الدخول"
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"email": "البريد الإلكتروني",
|
|
11
|
+
"password": "كلمة المرور",
|
|
12
|
+
"submit": "دخول",
|
|
13
|
+
"error": "بيانات الدخول غير صحيحة"
|
|
14
|
+
},
|
|
15
|
+
"home": {
|
|
16
|
+
"welcome": "مرحبًا بك في فلك"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app": {
|
|
3
|
+
"name": "Falak"
|
|
4
|
+
},
|
|
5
|
+
"nav": {
|
|
6
|
+
"home": "Home",
|
|
7
|
+
"login": "Login"
|
|
8
|
+
},
|
|
9
|
+
"auth": {
|
|
10
|
+
"email": "Email",
|
|
11
|
+
"password": "Password",
|
|
12
|
+
"submit": "Sign in",
|
|
13
|
+
"error": "Invalid credentials"
|
|
14
|
+
},
|
|
15
|
+
"home": {
|
|
16
|
+
"welcome": "Welcome to Falak"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createApp } from "vue";
|
|
2
|
+
import { createPinia } from "pinia";
|
|
3
|
+
import App from "./App.vue";
|
|
4
|
+
import router from "./router";
|
|
5
|
+
import i18n from "./i18n";
|
|
6
|
+
import "./assets/main.css";
|
|
7
|
+
|
|
8
|
+
const app = createApp(App);
|
|
9
|
+
|
|
10
|
+
app.use(createPinia());
|
|
11
|
+
app.use(router);
|
|
12
|
+
app.use(i18n);
|
|
13
|
+
|
|
14
|
+
app.mount("#app");
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRouter, createWebHistory } from "vue-router";
|
|
2
|
+
import { useAuthStore } from "@/stores/auth";
|
|
3
|
+
|
|
4
|
+
const routes = [
|
|
5
|
+
{ path: "/", name: "home", component: () => import("@/views/Home.vue") },
|
|
6
|
+
{ path: "/login", name: "login", component: () => import("@/views/Login.vue") },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const router = createRouter({
|
|
10
|
+
history: createWebHistory(),
|
|
11
|
+
routes,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
router.beforeEach((to) => {
|
|
15
|
+
const auth = useAuthStore();
|
|
16
|
+
if (to.meta.requiresAuth && !auth.userToken) {
|
|
17
|
+
return { name: "login" };
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export default router;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import Echo from "laravel-echo";
|
|
2
|
+
import Ably from "ably";
|
|
3
|
+
import api from "./api";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Realtime (Ably via Laravel Echo/Broadcasting).
|
|
7
|
+
*
|
|
8
|
+
* Private/presence channels are authorized by Laravel's broadcasting auth
|
|
9
|
+
* route (default: POST /broadcasting/auth), which must see the same
|
|
10
|
+
* X-API-Key + Authorization: Bearer <user token> headers as every other
|
|
11
|
+
* request — so we point Echo's authorizer at the shared `api` axios client
|
|
12
|
+
* instead of letting it make a bare fetch/XHR of its own.
|
|
13
|
+
*
|
|
14
|
+
* Backend needs (config/broadcasting.php):
|
|
15
|
+
* 'default' => 'ably',
|
|
16
|
+
* 'connections' => ['ably' => ['driver' => 'ably', 'key' => env('ABLY_KEY')]]
|
|
17
|
+
* and BroadcastServiceProvider registered so /broadcasting/auth exists.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let echo = null;
|
|
21
|
+
|
|
22
|
+
export function connect() {
|
|
23
|
+
if (echo) return echo;
|
|
24
|
+
|
|
25
|
+
echo = new Echo({
|
|
26
|
+
broadcaster: "ably",
|
|
27
|
+
key: import.meta.env.VITE_ABLY_PUBLIC_KEY, // Ably key's public/client half only
|
|
28
|
+
authorizer: (channel) => ({
|
|
29
|
+
authorize: (socketId, callback) => {
|
|
30
|
+
api
|
|
31
|
+
.post("/broadcasting/auth", {
|
|
32
|
+
socket_id: socketId,
|
|
33
|
+
channel_name: channel.name,
|
|
34
|
+
})
|
|
35
|
+
.then(({ data }) => callback(false, data))
|
|
36
|
+
.catch((err) => callback(true, err));
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return echo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function disconnect() {
|
|
45
|
+
echo?.disconnect();
|
|
46
|
+
echo = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Subscribe to a public channel, e.g. listenPublic('orders', '.OrderUpdated', cb) */
|
|
50
|
+
export function listenPublic(channelName, eventName, callback) {
|
|
51
|
+
return connect().channel(channelName).listen(eventName, callback);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Subscribe to a private channel scoped to the current user, e.g. `App.Models.User.5` */
|
|
55
|
+
export function listenPrivate(channelName, eventName, callback) {
|
|
56
|
+
return connect().private(channelName).listen(eventName, callback);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Subscribe to a presence channel (who's online), e.g. a shared room */
|
|
60
|
+
export function joinPresence(channelName, { here, joining, leaving } = {}) {
|
|
61
|
+
const presence = connect().join(channelName);
|
|
62
|
+
if (here) presence.here(here);
|
|
63
|
+
if (joining) presence.joining(joining);
|
|
64
|
+
if (leaving) presence.leaving(leaving);
|
|
65
|
+
return presence;
|
|
66
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { decrypt } from "@/utils/crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Central client for talking to the Laravel backend.
|
|
6
|
+
*
|
|
7
|
+
* Auth model (two headers on every request):
|
|
8
|
+
* - X-API-Key : static key identifying *this client app* (set per environment,
|
|
9
|
+
* checked by an app-level middleware before anything else runs).
|
|
10
|
+
* - Authorization: `Bearer <user token>` issued at login, identifying the *user*.
|
|
11
|
+
*
|
|
12
|
+
* This mirrors a common Laravel Sanctum-style setup where the API key gates which
|
|
13
|
+
* apps may talk to the API at all, and the bearer token scopes the request to a user.
|
|
14
|
+
*/
|
|
15
|
+
const api = axios.create({
|
|
16
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
17
|
+
headers: {
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
"X-API-Key": import.meta.env.VITE_API_KEY,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
api.interceptors.request.use((config) => {
|
|
24
|
+
// Read the token straight from storage (not the Pinia store) to avoid a
|
|
25
|
+
// circular import between this file and stores/auth.js.
|
|
26
|
+
const raw = localStorage.getItem("falak_user_token");
|
|
27
|
+
if (raw) {
|
|
28
|
+
const token = decrypt(raw);
|
|
29
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let onUnauthorized = null;
|
|
35
|
+
export function registerUnauthorizedHandler(fn) {
|
|
36
|
+
onUnauthorized = fn;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
api.interceptors.response.use(
|
|
40
|
+
(response) => response,
|
|
41
|
+
(error) => {
|
|
42
|
+
if (error.response?.status === 401 && onUnauthorized) {
|
|
43
|
+
onUnauthorized();
|
|
44
|
+
}
|
|
45
|
+
return Promise.reject(error);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export default api;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import api from "./api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Brevo (formerly Sendinblue) — transactional email & SMS.
|
|
5
|
+
*
|
|
6
|
+
* This replaces Firebase (which the previous falak-app generation used for
|
|
7
|
+
* auth emails / push). Brevo's API key is a backend secret, so every call
|
|
8
|
+
* here goes through your Laravel API, authenticated the same way as the
|
|
9
|
+
* rest of the app (X-API-Key + user bearer token via the shared `api` client).
|
|
10
|
+
*
|
|
11
|
+
* Expected Laravel routes (implement server-side with brevo/brevo-php or a
|
|
12
|
+
* plain HTTP client against https://api.brevo.com/v3):
|
|
13
|
+
* POST /brevo/email { to, template_id, params }
|
|
14
|
+
* POST /brevo/sms { to, message }
|
|
15
|
+
* POST /brevo/contacts { email, attributes, list_ids }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export async function sendTransactionalEmail({ to, templateId, params = {} }) {
|
|
19
|
+
const { data } = await api.post("/brevo/email", { to, template_id: templateId, params });
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function sendSms({ to, message }) {
|
|
24
|
+
const { data } = await api.post("/brevo/sms", { to, message });
|
|
25
|
+
return data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function upsertContact({ email, attributes = {}, listIds = [] }) {
|
|
29
|
+
const { data } = await api.post("/brevo/contacts", {
|
|
30
|
+
email,
|
|
31
|
+
attributes,
|
|
32
|
+
list_ids: listIds,
|
|
33
|
+
});
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineStore } from "pinia";
|
|
2
|
+
import { encrypt, decrypt } from "@/utils/crypto";
|
|
3
|
+
import api from "@/services/api";
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = "falak_user_token";
|
|
6
|
+
|
|
7
|
+
function loadToken() {
|
|
8
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
9
|
+
if (!raw) return null;
|
|
10
|
+
try {
|
|
11
|
+
return decrypt(raw) || null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useAuthStore = defineStore("auth", {
|
|
18
|
+
state: () => ({
|
|
19
|
+
userToken: loadToken(),
|
|
20
|
+
user: null,
|
|
21
|
+
}),
|
|
22
|
+
actions: {
|
|
23
|
+
setToken(token) {
|
|
24
|
+
this.userToken = token;
|
|
25
|
+
localStorage.setItem(STORAGE_KEY, encrypt(token));
|
|
26
|
+
},
|
|
27
|
+
clearToken() {
|
|
28
|
+
this.userToken = null;
|
|
29
|
+
this.user = null;
|
|
30
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
31
|
+
},
|
|
32
|
+
// Two-header auth: X-API-Key identifies the *app*, Authorization Bearer
|
|
33
|
+
// identifies the *user*. Both are required by the Laravel API middleware.
|
|
34
|
+
async login(email, password) {
|
|
35
|
+
const { data } = await api.post("/auth/login", { email, password });
|
|
36
|
+
this.setToken(data.token);
|
|
37
|
+
this.user = data.user;
|
|
38
|
+
return data.user;
|
|
39
|
+
},
|
|
40
|
+
async logout() {
|
|
41
|
+
try {
|
|
42
|
+
await api.post("/auth/logout");
|
|
43
|
+
} finally {
|
|
44
|
+
this.clearToken();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
async fetchProfile() {
|
|
48
|
+
const { data } = await api.get("/auth/me");
|
|
49
|
+
this.user = data;
|
|
50
|
+
return data;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import CryptoJS from "crypto-js";
|
|
2
|
+
|
|
3
|
+
// AES-256-CBC, key/IV pulled from .env (generated fresh per-project by the CLI).
|
|
4
|
+
// Use this for encrypting sensitive values you must keep in localStorage/sessionStorage
|
|
5
|
+
// (e.g. a cached user token) — never for anything you'd rather the backend handle.
|
|
6
|
+
const KEY = CryptoJS.enc.Hex.parse(import.meta.env.VITE_AES_KEY || "");
|
|
7
|
+
const IV = CryptoJS.enc.Hex.parse(import.meta.env.VITE_AES_IV || "");
|
|
8
|
+
|
|
9
|
+
export function encrypt(plainText) {
|
|
10
|
+
if (!KEY.words.length || !IV.words.length) {
|
|
11
|
+
console.warn("[crypto] VITE_AES_KEY / VITE_AES_IV are not set — refusing to encrypt.");
|
|
12
|
+
return plainText;
|
|
13
|
+
}
|
|
14
|
+
const encrypted = CryptoJS.AES.encrypt(plainText, KEY, {
|
|
15
|
+
iv: IV,
|
|
16
|
+
mode: CryptoJS.mode.CBC,
|
|
17
|
+
padding: CryptoJS.pad.Pkcs7,
|
|
18
|
+
});
|
|
19
|
+
return encrypted.toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function decrypt(cipherText) {
|
|
23
|
+
if (!KEY.words.length || !IV.words.length) return cipherText;
|
|
24
|
+
const decrypted = CryptoJS.AES.decrypt(cipherText, KEY, {
|
|
25
|
+
iv: IV,
|
|
26
|
+
mode: CryptoJS.mode.CBC,
|
|
27
|
+
padding: CryptoJS.pad.Pkcs7,
|
|
28
|
+
});
|
|
29
|
+
return decrypted.toString(CryptoJS.enc.Utf8);
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useI18n } from "vue-i18n";
|
|
3
|
+
import { useAuthStore } from "@/stores/auth";
|
|
4
|
+
|
|
5
|
+
const { t } = useI18n();
|
|
6
|
+
const auth = useAuthStore();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="max-w-2xl mx-auto text-center py-16">
|
|
11
|
+
<h1 class="text-3xl font-bold mb-4">{{ t("home.welcome") }}</h1>
|
|
12
|
+
<p v-if="auth.userToken" class="text-gray-600">
|
|
13
|
+
{{ auth.user?.name || auth.userToken.slice(0, 12) + "..." }}
|
|
14
|
+
</p>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { useI18n } from "vue-i18n";
|
|
4
|
+
import { useRouter } from "vue-router";
|
|
5
|
+
import { useAuthStore } from "@/stores/auth";
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const auth = useAuthStore();
|
|
10
|
+
|
|
11
|
+
const email = ref("");
|
|
12
|
+
const password = ref("");
|
|
13
|
+
const error = ref("");
|
|
14
|
+
const loading = ref(false);
|
|
15
|
+
|
|
16
|
+
async function submit() {
|
|
17
|
+
error.value = "";
|
|
18
|
+
loading.value = true;
|
|
19
|
+
try {
|
|
20
|
+
await auth.login(email.value, password.value);
|
|
21
|
+
router.push({ name: "home" });
|
|
22
|
+
} catch (e) {
|
|
23
|
+
error.value = t("auth.error");
|
|
24
|
+
} finally {
|
|
25
|
+
loading.value = false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<form @submit.prevent="submit" class="max-w-sm mx-auto flex flex-col gap-4">
|
|
32
|
+
<label class="flex flex-col gap-1">
|
|
33
|
+
<span>{{ t("auth.email") }}</span>
|
|
34
|
+
<input v-model="email" type="email" required class="border rounded px-3 py-2" />
|
|
35
|
+
</label>
|
|
36
|
+
<label class="flex flex-col gap-1">
|
|
37
|
+
<span>{{ t("auth.password") }}</span>
|
|
38
|
+
<input v-model="password" type="password" required class="border rounded px-3 py-2" />
|
|
39
|
+
</label>
|
|
40
|
+
<p v-if="error" class="text-red-600 text-sm">{{ error }}</p>
|
|
41
|
+
<button
|
|
42
|
+
type="submit"
|
|
43
|
+
:disabled="loading"
|
|
44
|
+
class="bg-gray-900 text-white rounded px-4 py-2 disabled:opacity-50"
|
|
45
|
+
>
|
|
46
|
+
{{ t("auth.submit") }}
|
|
47
|
+
</button>
|
|
48
|
+
</form>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
export default {
|
|
3
|
+
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
|
4
|
+
theme: {
|
|
5
|
+
extend: {
|
|
6
|
+
fontFamily: {
|
|
7
|
+
arabic: ["'IBM Plex Sans Arabic'", "Almarai", "sans-serif"],
|
|
8
|
+
sans: ["Almarai", "'IBM Plex Sans Arabic'", "sans-serif"],
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
plugins: [],
|
|
13
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import vue from "@vitejs/plugin-vue";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
plugins: [vue()],
|
|
10
|
+
resolve: {
|
|
11
|
+
alias: {
|
|
12
|
+
"@": path.resolve(__dirname, "src"),
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
server: {
|
|
16
|
+
port: 5173,
|
|
17
|
+
},
|
|
18
|
+
});
|