diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js
index 251c149..25c6700 100644
--- a/backend/src/auth/auth.js
+++ b/backend/src/auth/auth.js
@@ -1,13 +1,11 @@
const config = require('../config');
const providers = config.providers;
-const helpers = require('../helpers');
-const db = require('../db/models');
-
const passport = require('passport');
const JWTstrategy = require('passport-jwt').Strategy;
const ExtractJWT = require('passport-jwt').ExtractJwt;
const GoogleStrategy = require('passport-google-oauth2').Strategy;
const MicrosoftStrategy = require('passport-microsoft').Strategy;
+const AuthService = require('../services/auth');
const UsersDBApi = require('../db/api/users');
@@ -56,13 +54,7 @@ passport.use(new MicrosoftStrategy({
));
function socialStrategy(email, profile, provider, done) {
- db.users.findOrCreate({where: {email, provider}}).then(([user, created]) => {
- const body = {
- id: user.id,
- email: user.email,
- name: profile.displayName,
- };
- const token = helpers.jwtSign({user: body});
- return done(null, {token});
- });
+ AuthService.socialSignin(email, profile, provider)
+ .then((token) => done(null, { token }))
+ .catch((error) => done(error));
}
diff --git a/backend/src/db/migrations/20260618120000-add-email-otp-to-users.js b/backend/src/db/migrations/20260618120000-add-email-otp-to-users.js
new file mode 100644
index 0000000..e2d5029
--- /dev/null
+++ b/backend/src/db/migrations/20260618120000-add-email-otp-to-users.js
@@ -0,0 +1,94 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ const table = await queryInterface.describeTable('users');
+
+ if (!table.emailOtpHash) {
+ await queryInterface.addColumn(
+ 'users',
+ 'emailOtpHash',
+ {
+ type: Sequelize.DataTypes.TEXT,
+ allowNull: true,
+ },
+ { transaction },
+ );
+ }
+
+ if (!table.emailOtpExpiresAt) {
+ await queryInterface.addColumn(
+ 'users',
+ 'emailOtpExpiresAt',
+ {
+ type: Sequelize.DataTypes.DATE,
+ allowNull: true,
+ },
+ { transaction },
+ );
+ }
+
+ if (!table.emailOtpLastSentAt) {
+ await queryInterface.addColumn(
+ 'users',
+ 'emailOtpLastSentAt',
+ {
+ type: Sequelize.DataTypes.DATE,
+ allowNull: true,
+ },
+ { transaction },
+ );
+ }
+
+ if (!table.emailOtpAttempts) {
+ await queryInterface.addColumn(
+ 'users',
+ 'emailOtpAttempts',
+ {
+ type: Sequelize.DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ { transaction },
+ );
+ }
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+
+ async down(queryInterface) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ const table = await queryInterface.describeTable('users');
+
+ if (table.emailOtpAttempts) {
+ await queryInterface.removeColumn('users', 'emailOtpAttempts', { transaction });
+ }
+
+ if (table.emailOtpLastSentAt) {
+ await queryInterface.removeColumn('users', 'emailOtpLastSentAt', { transaction });
+ }
+
+ if (table.emailOtpExpiresAt) {
+ await queryInterface.removeColumn('users', 'emailOtpExpiresAt', { transaction });
+ }
+
+ if (table.emailOtpHash) {
+ await queryInterface.removeColumn('users', 'emailOtpHash', { transaction });
+ }
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+};
diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js
index 9ece124..d51711c 100644
--- a/backend/src/db/models/users.js
+++ b/backend/src/db/models/users.js
@@ -2,7 +2,6 @@ const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
-const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const users = sequelize.define(
@@ -95,6 +94,37 @@ passwordResetTokenExpiresAt: {
+ },
+
+emailOtpHash: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+emailOtpExpiresAt: {
+ type: DataTypes.DATE,
+
+
+
+ },
+
+emailOtpLastSentAt: {
+ type: DataTypes.DATE,
+
+
+
+ },
+
+emailOtpAttempts: {
+ type: DataTypes.INTEGER,
+
+ allowNull: false,
+ defaultValue: 0,
+
+
+
},
provider: {
@@ -229,8 +259,8 @@ provider: {
};
- users.beforeCreate((users, options) => {
- users = trimStringFields(users);
+ users.beforeCreate((users) => {
+ trimStringFields(users);
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true;
@@ -250,8 +280,8 @@ provider: {
}
});
- users.beforeUpdate((users, options) => {
- users = trimStringFields(users);
+ users.beforeUpdate((users) => {
+ trimStringFields(users);
});
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index d6f29e8..3b8ad23 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -84,8 +84,12 @@ router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) =>
throw new ForbiddenError();
}
- const payload = req.currentUser;
+ const payload = { ...req.currentUser };
delete payload.password;
+ delete payload.emailOtpHash;
+ delete payload.emailOtpExpiresAt;
+ delete payload.emailOtpLastSentAt;
+ delete payload.emailOtpAttempts;
res.status(200).send(payload);
});
@@ -171,12 +175,31 @@ router.get('/email-configured', (req, res) => {
res.status(200).send(payload);
});
+router.post('/otp/request', wrapAsync(async (req, res) => {
+ const payload = await AuthService.requestEmailOtp(req.body.email);
+ res.status(200).send(payload);
+}));
+
+router.post('/otp/verify', wrapAsync(async (req, res) => {
+ const payload = await AuthService.verifyEmailOtp(req.body.email, req.body.otp);
+ res.status(200).send(payload);
+}));
+
router.get('/signin/google', (req, res, next) => {
- passport.authenticate("google", {scope: ["profile", "email"], state: req.query.app})(req, res, next);
+ passport.authenticate("google", {
+ scope: ["profile", "email"],
+ state: req.query.app,
+ callbackURL: oauthCallbackUrl(req, 'google'),
+ })(req, res, next);
});
-router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}),
-
+router.get('/signin/google/callback', (req, res, next) => {
+ passport.authenticate("google", {
+ failureRedirect: "/login",
+ session: false,
+ callbackURL: oauthCallbackUrl(req, 'google'),
+ })(req, res, next);
+ },
function (req, res) {
socialRedirect(res, req.query.state, req.user.token, config);
}
@@ -185,14 +208,18 @@ router.get('/signin/google/callback', passport.authenticate("google", {failureRe
router.get('/signin/microsoft', (req, res, next) => {
passport.authenticate("microsoft", {
scope: ["https://graph.microsoft.com/user.read openid"],
- state: req.query.app
+ state: req.query.app,
+ callbackURL: oauthCallbackUrl(req, 'microsoft'),
})(req, res, next);
});
-router.get('/signin/microsoft/callback', passport.authenticate("microsoft", {
- failureRedirect: "/login",
- session: false
- }),
+router.get('/signin/microsoft/callback', (req, res, next) => {
+ passport.authenticate("microsoft", {
+ failureRedirect: "/login",
+ session: false,
+ callbackURL: oauthCallbackUrl(req, 'microsoft'),
+ })(req, res, next);
+ },
function (req, res) {
socialRedirect(res, req.query.state, req.user.token, config);
}
@@ -200,8 +227,29 @@ router.get('/signin/microsoft/callback', passport.authenticate("microsoft", {
router.use('/', require('../helpers').commonErrorHandler);
+function oauthCallbackUrl(req, provider) {
+ const forwardedProto = req.get('x-forwarded-proto');
+ const forwardedHost = req.get('x-forwarded-host');
+ const protocol = (forwardedProto || req.protocol || 'http').split(',')[0].trim();
+ const host = (forwardedHost || req.get('host')).split(',')[0].trim();
+
+ return `${protocol}://${host}/api/auth/signin/${provider}/callback`;
+}
+
function socialRedirect(res, state, token, config) {
- res.redirect(config.uiUrl + "/login?token=" + token);
+ let uiUrl = config.backUrl || config.uiUrl;
+
+ if (state) {
+ try {
+ const decodedState = decodeURIComponent(state);
+ const parsedState = new URL(decodedState);
+ uiUrl = parsedState.origin;
+ } catch (error) {
+ console.error('Invalid social redirect state:', error);
+ }
+ }
+
+ res.redirect(`${uiUrl}/login?token=${encodeURIComponent(token)}`);
}
module.exports = router;
diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js
index 2862da4..a9d33dc 100644
--- a/backend/src/services/auth.js
+++ b/backend/src/services/auth.js
@@ -2,15 +2,269 @@ const UsersDBApi = require('../db/api/users');
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const bcrypt = require('bcrypt');
+const crypto = require('crypto');
const EmailAddressVerificationEmail = require('./email/list/addressVerification');
+const EmailOtpEmail = require('./email/list/emailOtp');
const InvitationEmail = require("./email/list/invitation");
const PasswordResetEmail = require('./email/list/passwordReset');
const EmailSender = require('./email');
const config = require('../config');
const helpers = require('../helpers');
+const db = require('../db/models');
class Auth {
+ static normalizeEmail(email) {
+ if (!email || typeof email !== 'string' || !email.includes('@')) {
+ throw new ValidationError('auth.invalidEmail');
+ }
+
+ return email.trim().toLowerCase();
+ }
+
+ static async findUserModelByEmail(email, transaction) {
+ return db.users.findOne({
+ where: {
+ email: {
+ [db.Sequelize.Op.iLike]: email,
+ },
+ },
+ transaction,
+ });
+ }
+
+ static async ensureDefaultMemberRole(user, transaction) {
+ const currentRole = await user.getApp_role({ transaction });
+
+ if (currentRole?.id) {
+ return currentRole;
+ }
+
+ const role = await db.roles.findOne({
+ where: { name: config.roles?.user || 'Registered User' },
+ transaction,
+ });
+
+ if (role?.id) {
+ await user.setApp_role(role.id, { transaction });
+ }
+
+ return role;
+ }
+
+ static async createOtpUser(email, transaction) {
+ const randomPassword = crypto.randomBytes(20).toString('hex');
+ const hashedPassword = await bcrypt.hash(
+ randomPassword,
+ config.bcrypt.saltRounds,
+ );
+
+ const user = await db.users.create(
+ {
+ email,
+ firstName: email.split('@')[0],
+ password: hashedPassword,
+ provider: config.providers.LOCAL,
+ emailVerified: false,
+ },
+ { transaction },
+ );
+
+ await this.ensureDefaultMemberRole(user, transaction);
+
+ return user;
+ }
+
+ static async requestEmailOtp(email) {
+ const normalizedEmail = this.normalizeEmail(email);
+
+ if (!EmailSender.isConfigured) {
+ throw new ValidationError('auth.emailOtp.emailNotConfigured');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ let user = await this.findUserModelByEmail(normalizedEmail, transaction);
+
+ if (user?.disabled) {
+ throw new ValidationError('auth.userDisabled');
+ }
+
+ if (!user) {
+ user = await this.createOtpUser(normalizedEmail, transaction);
+ }
+
+ if (user.emailOtpLastSentAt) {
+ const secondsSinceLastOtp = (Date.now() - new Date(user.emailOtpLastSentAt).getTime()) / 1000;
+
+ if (secondsSinceLastOtp < 60) {
+ throw new ValidationError('auth.emailOtp.tooManyRequests');
+ }
+ }
+
+ const otp = String(crypto.randomInt(100000, 1000000));
+ const expiresInMinutes = 10;
+ const emailOtpHash = await bcrypt.hash(otp, config.bcrypt.saltRounds);
+
+ await user.update(
+ {
+ emailOtpHash,
+ emailOtpExpiresAt: new Date(Date.now() + expiresInMinutes * 60 * 1000),
+ emailOtpLastSentAt: new Date(),
+ emailOtpAttempts: 0,
+ },
+ { transaction },
+ );
+
+ const emailOtpEmail = new EmailOtpEmail(normalizedEmail, otp, expiresInMinutes);
+ await new EmailSender(emailOtpEmail).send();
+
+ await transaction.commit();
+
+ return {
+ success: true,
+ email: normalizedEmail,
+ expiresInMinutes,
+ };
+ } catch (error) {
+ if (!transaction.finished) {
+ await transaction.rollback();
+ }
+ console.error('Email OTP request failed:', error);
+ throw error;
+ }
+ }
+
+ static async verifyEmailOtp(email, otp) {
+ const normalizedEmail = this.normalizeEmail(email);
+ const normalizedOtp = String(otp || '').trim();
+
+ if (!/^\d{6}$/.test(normalizedOtp)) {
+ throw new ValidationError('auth.emailOtp.invalid');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const user = await this.findUserModelByEmail(normalizedEmail, transaction);
+
+ if (!user || user.disabled) {
+ throw new ValidationError(user?.disabled ? 'auth.userDisabled' : 'auth.emailOtp.invalid');
+ }
+
+ const attempts = Number(user.emailOtpAttempts || 0);
+
+ if (attempts >= 5) {
+ throw new ValidationError('auth.emailOtp.tooManyAttempts');
+ }
+
+ if (!user.emailOtpHash || !user.emailOtpExpiresAt || new Date(user.emailOtpExpiresAt).getTime() < Date.now()) {
+ throw new ValidationError('auth.emailOtp.invalid');
+ }
+
+ const otpMatches = await bcrypt.compare(normalizedOtp, user.emailOtpHash);
+
+ if (!otpMatches) {
+ await user.update(
+ {
+ emailOtpAttempts: attempts + 1,
+ },
+ { transaction },
+ );
+ await transaction.commit();
+ throw new ValidationError('auth.emailOtp.invalid');
+ }
+
+ await this.ensureDefaultMemberRole(user, transaction);
+
+ await user.update(
+ {
+ emailVerified: true,
+ emailOtpHash: null,
+ emailOtpExpiresAt: null,
+ emailOtpAttempts: 0,
+ },
+ { transaction },
+ );
+
+ await transaction.commit();
+
+ const data = {
+ user: {
+ id: user.id,
+ email: user.email,
+ },
+ };
+
+ return helpers.jwtSign(data);
+ } catch (error) {
+ if (!transaction.finished) {
+ await transaction.rollback();
+ }
+ console.error('Email OTP verification failed:', error);
+ throw error;
+ }
+ }
+
+ static async socialSignin(email, profile, provider) {
+ const normalizedEmail = this.normalizeEmail(email);
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ let user = await this.findUserModelByEmail(normalizedEmail, transaction);
+
+ if (user?.disabled) {
+ throw new ValidationError('auth.userDisabled');
+ }
+
+ if (!user) {
+ const displayName = profile.displayName || profile.name?.givenName || normalizedEmail.split('@')[0];
+
+ user = await db.users.create(
+ {
+ email: normalizedEmail,
+ firstName: displayName,
+ provider,
+ emailVerified: true,
+ },
+ { transaction },
+ );
+
+ await this.ensureDefaultMemberRole(user, transaction);
+ } else {
+ const updatePayload = {
+ emailVerified: true,
+ };
+
+ if (!user.provider) {
+ updatePayload.provider = provider;
+ }
+
+ await user.update(updatePayload, { transaction });
+ }
+
+ await transaction.commit();
+
+ const data = {
+ user: {
+ id: user.id,
+ email: user.email,
+ name: profile.displayName,
+ },
+ };
+
+ return helpers.jwtSign(data);
+ } catch (error) {
+ if (!transaction.finished) {
+ await transaction.rollback();
+ }
+ console.error('Social sign-in failed:', error);
+ throw error;
+ }
+ }
+
static async signup(email, password, options = {}, host) {
+ email = this.normalizeEmail(email);
const user = await UsersDBApi.findBy({email});
const hashedPassword = await bcrypt.hash(
@@ -81,7 +335,8 @@ class Auth {
return helpers.jwtSign(data);
}
- static async signin(email, password, options = {}) {
+ static async signin(email, password) {
+ email = this.normalizeEmail(email);
const user = await UsersDBApi.findBy({email});
if (!user) {
diff --git a/backend/src/services/email/list/emailOtp.js b/backend/src/services/email/list/emailOtp.js
new file mode 100644
index 0000000..67d14d0
--- /dev/null
+++ b/backend/src/services/email/list/emailOtp.js
@@ -0,0 +1,58 @@
+const { getNotification } = require('../../notifications/helpers');
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+module.exports = class EmailOtpEmail {
+ constructor(to, otp, expiresInMinutes) {
+ this.to = to;
+ this.otp = otp;
+ this.expiresInMinutes = expiresInMinutes;
+ }
+
+ get subject() {
+ return `Kode OTP ${getNotification('app.title')}`;
+ }
+
+ async html() {
+ const appTitle = escapeHtml(getNotification('app.title'));
+ const otp = escapeHtml(this.otp);
+ const expiresInMinutes = escapeHtml(this.expiresInMinutes);
+
+ return `
+
+
+
+
+
+
+
+
+
+
Halo,
+
Gunakan kode berikut untuk masuk atau daftar sebagai Anggota:
+
${otp}
+
Kode ini berlaku selama ${expiresInMinutes} menit.
+
Jika Anda tidak meminta kode ini, abaikan email ini.
+
+
+
+
+
+ `;
+ }
+};
diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js
index 55e7af5..2a12577 100644
--- a/backend/src/services/notifications/list.js
+++ b/backend/src/services/notifications/list.js
@@ -26,6 +26,12 @@ const errors = {
'Email verification link is invalid or has expired',
error: `Email not recognized`,
},
+ emailOtp: {
+ emailNotConfigured: 'Email OTP is not configured yet. Please configure SMTP email first.',
+ invalid: 'OTP code is invalid or has expired',
+ tooManyRequests: 'Please wait before requesting another OTP code',
+ tooManyAttempts: 'Too many invalid OTP attempts. Please request a new code',
+ },
},
iam: {
diff --git a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx
index e65e7cb..08f5293 100644
--- a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx
+++ b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx
@@ -10,7 +10,8 @@ import CardBox from '../CardBox';
import SectionMain from '../SectionMain';
import SectionTitleLineWithButton from '../SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
-import { useAppSelector } from '../../stores/hooks';
+import { useAppDispatch, useAppSelector } from '../../stores/hooks';
+import { aiResponse } from '../../stores/openAiSlice';
import {
defaultGeoSeekLocation,
GeoSeekItem,
@@ -132,6 +133,15 @@ type GeoSeekApiMeta = {
expandedForNearest: boolean;
};
+type AiLocationPlan = {
+ allowed: boolean;
+ keyword: string;
+ radiusKm: number;
+ reason: string;
+ categoryHint?: string;
+ safetyNote?: string;
+};
+
const typeLabels: Record = {
place: 'Tempat',
product: 'Produk',
@@ -166,6 +176,13 @@ const emptyApiMeta: GeoSeekApiMeta = {
const radiusOptions = [1, 5, 10, 20];
+const aiLocationExamples = [
+ 'Cari ATM terdekat dari lokasi saya',
+ 'Saya di Bogor, cari apotek buka sekarang radius 5 km',
+ 'Cari bengkel motor yang dekat dan masih buka',
+ 'Temukan tempat makan murah yang bisa antar',
+];
+
const apiQueryByModule: Partial> = {
home: 'produk jasa umkm lokal',
search: 'produk jasa umkm lokal',
@@ -189,6 +206,120 @@ const apiQueryByModule: Partial> = {
const getApiQuery = (query: string, moduleKey: GeoSeekModuleKey) => query.trim() || apiQueryByModule[moduleKey] || '';
+const normalizeAiRadius = (value: unknown) => {
+ const number = Number(value);
+
+ if (!Number.isFinite(number)) return 5;
+ if (number <= 1) return 1;
+ if (number <= 5) return 5;
+ if (number <= 10) return 10;
+
+ return 20;
+};
+
+const getAiResponseText = (response: any): string => {
+ const payload = response?.data || response;
+
+ if (typeof payload === 'string') return payload;
+ if (!payload || typeof payload !== 'object') return '';
+
+ if (Array.isArray(payload.output)) {
+ return payload.output
+ .flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
+ .filter((block) => block?.type === 'output_text' && typeof block.text === 'string')
+ .map((block) => block.text)
+ .join('')
+ .trim();
+ }
+
+ if (typeof payload.text === 'string') return payload.text;
+ if (typeof payload.message === 'string') return payload.message;
+
+ return '';
+};
+
+const parseAiLocationPlan = (text: string, fallbackKeyword: string): AiLocationPlan => {
+ const stripped = text
+ .trim()
+ .replace(/^```json/i, '')
+ .replace(/^```/i, '')
+ .replace(/```$/i, '')
+ .trim();
+ const jsonText = stripped.startsWith('{') ? stripped : stripped.match(/\{[\s\S]*\}/)?.[0];
+
+ if (!jsonText) {
+ throw new Error('AI tidak mengembalikan JSON rencana lokasi.');
+ }
+
+ const parsed = JSON.parse(jsonText);
+ const allowed = parsed.allowed !== false;
+ const keyword = String(parsed.keyword || fallbackKeyword || '').replace(/\s+/g, ' ').trim().slice(0, 90);
+ const reason = String(parsed.reason || 'AI membuat rencana pencarian berdasarkan kalimat Anda.').trim();
+ const safetyNote = parsed.safetyNote ? String(parsed.safetyNote).trim() : undefined;
+ const categoryHint = parsed.categoryHint ? String(parsed.categoryHint).trim() : undefined;
+
+ if (allowed && !keyword) {
+ throw new Error('AI tidak mengembalikan keyword pencarian.');
+ }
+
+ return {
+ allowed,
+ keyword,
+ radiusKm: normalizeAiRadius(parsed.radiusKm),
+ reason,
+ categoryHint,
+ safetyNote,
+ };
+};
+
+const buildLocalLocationPlan = (input: string, fallbackRadiusKm: number): AiLocationPlan => {
+ const normalized = normalizeTagText(input);
+
+ if (/lacak|melacak|stalking|stalk|lokasi real time|lokasi realtime|chat pribadi|akun private|akun privat|sadap|bajak/.test(normalized)) {
+ return {
+ allowed: false,
+ keyword: '',
+ radiusKm: normalizeAiRadius(fallbackRadiusKm),
+ reason: 'Permintaan mengarah ke pelacakan pribadi atau data non-publik.',
+ safetyNote: 'GeoSeek hanya mencari lokasi, bisnis, produk, jasa, dan informasi publik. Pelacakan orang pribadi atau akun private tidak didukung.',
+ };
+ }
+
+ const explicitRadius = input.match(/(\d+(?:[,.]\d+)?)\s*(?:km|kilometer)/i)?.[1];
+ const radiusKm = explicitRadius
+ ? normalizeAiRadius(explicitRadius.replace(',', '.'))
+ : /terdekat|dekat|sekitar|jalan kaki/.test(normalized)
+ ? 1
+ : normalizeAiRadius(fallbackRadiusKm);
+
+ const keywordRules: Array<{ pattern: RegExp; keyword: string; categoryHint: string }> = [
+ { pattern: /\batm\b|bank|tunai|tarik uang|setor/, keyword: 'atm bank tunai', categoryHint: 'Finansial' },
+ { pattern: /apotek|obat|klinik|dokter|vitamin|kesehatan/, keyword: 'apotek klinik obat', categoryHint: 'Kesehatan' },
+ { pattern: /bengkel|tambal ban|ban|motor|mobil|otomotif|servis|service/, keyword: 'bengkel otomotif servis', categoryHint: 'Otomotif' },
+ { pattern: /makan|makanan|kuliner|restoran|warung|cafe|kopi|nasi|murah/, keyword: 'kuliner makanan murah', categoryHint: 'Kuliner' },
+ { pattern: /hotel|penginapan|wisata|travel|tour/, keyword: 'hotel wisata', categoryHint: 'Wisata' },
+ { pattern: /kurir|antar|kirim|ongkir|delivery/, keyword: 'kurir antar lokal', categoryHint: 'Kurir' },
+ { pattern: /pupuk|tani|pertanian|bibit|organik/, keyword: 'pupuk pertanian', categoryHint: 'Pertanian' },
+ { pattern: /toko|minimarket|market|supermarket|produk|belanja/, keyword: 'toko minimarket produk', categoryHint: 'Toko' },
+ ];
+ const matchedRule = keywordRules.find((rule) => rule.pattern.test(normalized));
+ const fallbackKeyword = normalized
+ .split(/\s+/)
+ .filter((word) => word.length > 2 && !['cari', 'saya', 'dari', 'yang', 'dan', 'atau', 'untuk', 'lokasi', 'radius', 'dekat', 'terdekat'].includes(word))
+ .slice(0, 6)
+ .join(' ')
+ || input.trim();
+
+ return {
+ allowed: true,
+ keyword: matchedRule?.keyword || fallbackKeyword,
+ radiusKm,
+ reason: 'Fallback lokal membaca intent pencarian saat AI proxy belum tersedia.',
+ categoryHint: matchedRule?.categoryHint || 'Pencarian umum',
+ safetyNote: 'AI proxy belum tersedia, jadi GeoSeek memakai parser lokal sementara.',
+ };
+};
+
const toOptionalNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') return undefined;
@@ -687,6 +818,10 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
const activeModule = getModule(normalizedModuleKey);
const [query, setQuery] = useState(getDefaultQuery(activeModule.key));
const [radiusKm, setRadiusKm] = useState(5);
+ const [aiLocationInput, setAiLocationInput] = useState(aiLocationExamples[0]);
+ const [aiLocationPlan, setAiLocationPlan] = useState(null);
+ const [aiLocationStatus, setAiLocationStatus] = useState('AI siap menerjemahkan kalimat bebas menjadi keyword dan radius pencarian GeoSeek.');
+ const [isAiLocationLoading, setIsAiLocationLoading] = useState(false);
const [smartInput, setSmartInput] = useState(sampleSmartInputs[0]);
const [actionStatus, setActionStatus] = useState('Sistem otomasi siap digunakan. Pilih aksi cepat atau jalankan Smart Input.');
const [publishedItems, setPublishedItems] = useState([]);
@@ -700,6 +835,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
const [apiError, setApiError] = useState('');
const [apiMeta, setApiMeta] = useState(emptyApiMeta);
const [searchRequestVersion, setSearchRequestVersion] = useState(0);
+ const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const activeDistancePriority = useMemo(() => getDistancePriority(radiusKm), [radiusKm]);
@@ -804,6 +940,89 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
recordAction(`Draft “${newItem.name}” dipublikasikan sebagai ${typeLabels[newItem.type]} lokal dan langsung masuk hasil GeoSeek.`, 'Publikasi Smart Input');
};
+ const runAiLocationFinder = async () => {
+ const trimmedInput = aiLocationInput.trim();
+
+ if (!trimmedInput) {
+ setAiLocationStatus('Tulis dulu kebutuhan lokasi Anda, misalnya “cari ATM terdekat”.');
+ return;
+ }
+
+ setIsAiLocationLoading(true);
+ setAiLocationStatus('AI sedang memahami kebutuhan lokasi dan menyiapkan keyword pencarian...');
+
+ const payload = {
+ input: [
+ {
+ role: 'system',
+ content: [
+ 'Anda adalah AI Pencari Lokasi untuk GeoSeek.',
+ 'Ubah kalimat bebas pengguna menjadi rencana pencarian tempat, produk, jasa, atau bisnis publik.',
+ 'Jangan bantu melacak orang pribadi, akun private, chat WhatsApp pribadi, lokasi real-time personal, atau data non-publik.',
+ 'Jika permintaan tidak aman atau bersifat pelacakan pribadi, balas JSON dengan allowed false dan safetyNote singkat.',
+ 'Balas hanya JSON valid tanpa markdown.',
+ 'Format: {"allowed":true,"keyword":"keyword 2-6 kata","radiusKm":5,"reason":"alasan singkat","categoryHint":"kategori opsional","safetyNote":"catatan opsional"}.',
+ 'Pilih radiusKm hanya salah satu dari 1, 5, 10, atau 20.',
+ 'Untuk permintaan ATM gunakan keyword yang jelas seperti "atm bank tunai".',
+ ].join(' '),
+ },
+ {
+ role: 'user',
+ content: [
+ `Permintaan pengguna: ${trimmedInput}`,
+ `Modul GeoSeek aktif: ${activeModule.menuLabel}`,
+ `Lokasi aktif: ${resolvedLocation.label}`,
+ `Radius saat ini: ${radiusKm} km`,
+ 'Buat rencana yang bisa dipakai untuk endpoint /public/places.',
+ ].join('\n'),
+ },
+ ],
+ options: { poll_interval: 3, poll_timeout: 120 },
+ };
+
+ try {
+ const response = await dispatch(aiResponse(payload)).unwrap();
+ const responseText = getAiResponseText(response);
+ const plan = parseAiLocationPlan(responseText, trimmedInput);
+
+ setAiLocationPlan(plan);
+
+ if (!plan.allowed) {
+ const safetyMessage = plan.safetyNote || 'GeoSeek hanya mendukung pencarian tempat, bisnis, produk, dan data publik.';
+
+ setAiLocationStatus(safetyMessage);
+ recordAction(`AI menolak permintaan karena alasan keamanan: ${safetyMessage}`, 'AI Pencari Lokasi');
+ return;
+ }
+
+ setQuery(plan.keyword);
+ setRadiusKm(plan.radiusKm);
+ setSearchRequestVersion((version) => version + 1);
+ setAiLocationStatus(`AI menjalankan pencarian “${plan.keyword}” radius ${plan.radiusKm} km. ${plan.reason}`);
+ recordAction(`AI menerjemahkan “${trimmedInput}” menjadi pencarian “${plan.keyword}” radius ${plan.radiusKm} km.`, 'AI Pencari Lokasi');
+ } catch (error) {
+ console.error('AI Pencari Lokasi gagal, memakai fallback lokal:', { error, input: trimmedInput, payload });
+
+ const fallbackPlan = buildLocalLocationPlan(trimmedInput, radiusKm);
+ setAiLocationPlan(fallbackPlan);
+
+ if (!fallbackPlan.allowed) {
+ const safetyMessage = fallbackPlan.safetyNote || 'GeoSeek hanya mendukung pencarian tempat, bisnis, produk, jasa, dan data publik.';
+
+ setAiLocationStatus(safetyMessage);
+ recordAction(`AI/fallback menolak permintaan karena alasan keamanan: ${safetyMessage}`, 'AI Pencari Lokasi');
+ } else {
+ setQuery(fallbackPlan.keyword);
+ setRadiusKm(fallbackPlan.radiusKm);
+ setSearchRequestVersion((version) => version + 1);
+ setAiLocationStatus(`AI proxy belum tersedia, fallback lokal menjalankan “${fallbackPlan.keyword}” radius ${fallbackPlan.radiusKm} km. ${fallbackPlan.reason}`);
+ recordAction(`Fallback AI menerjemahkan “${trimmedInput}” menjadi pencarian “${fallbackPlan.keyword}” radius ${fallbackPlan.radiusKm} km.`, 'AI Pencari Lokasi');
+ }
+ } finally {
+ setIsAiLocationLoading(false);
+ }
+ };
+
useEffect(() => {
try {
const storedItems = window.localStorage.getItem(localPublishedItemsKey);
@@ -1071,6 +1290,74 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
)}
+
+
+
+
+
AI Pencari Lokasi
+
Cari tempat dengan bahasa bebas
+
+ Tulis kebutuhan seperti “cari ATM terdekat” atau “apotek buka sekarang”. AI akan mengubahnya menjadi keyword dan radius, lalu GeoSeek menjalankan pencarian lokasi publik.
+
+
+
+
+
+
+
+
+
+ Perintah AI
+
+
+
{
+ runAiLocationFinder();
+ }}
+ />
+
+ Aman: AI hanya mencari lokasi/tempat/bisnis publik, bukan melacak orang pribadi, chat, atau akun private.
+
+
+
+
+
+ {aiLocationExamples.map((example) => (
+ setAiLocationInput(example)}
+ >
+ {example}
+
+ ))}
+
+
+
+
+ Status AI: {aiLocationStatus}
+
+
+ Rencana: {' '}
+ {aiLocationPlan?.allowed
+ ? `${aiLocationPlan.keyword} • ${aiLocationPlan.radiusKm} km`
+ : aiLocationPlan?.safetyNote || 'Belum ada rencana AI'}
+
+
+
+
diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx
index 48dedd9..f82a52f 100644
--- a/frontend/src/pages/dashboard.tsx
+++ b/frontend/src/pages/dashboard.tsx
@@ -1,497 +1,673 @@
import * as icon from '@mdi/js';
-import Head from 'next/head'
-import React from 'react'
+import Head from 'next/head';
+import React from 'react';
import axios from 'axios';
-import type { ReactElement } from 'react'
-import LayoutAuthenticated from '../layouts/Authenticated'
-import SectionMain from '../components/SectionMain'
-import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
-import BaseIcon from "../components/BaseIcon";
-import { getPageTitle } from '../config'
-import Link from "next/link";
+import type { ReactElement, ReactNode } from 'react';
+import Link from 'next/link';
-import { hasPermission } from "../helpers/userPermissions";
+import LayoutAuthenticated from '../layouts/Authenticated';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import BaseIcon from '../components/BaseIcon';
+import { getPageTitle } from '../config';
+import { hasPermission } from '../helpers/userPermissions';
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
-
import { useAppDispatch, useAppSelector } from '../stores/hooks';
+
+type CountValue = number | string | null;
+
+const entityCards = [
+ {
+ key: 'users',
+ permission: 'READ_USERS',
+ label: 'Pengguna',
+ description: 'Akun admin dan anggota',
+ href: '/users/users-list',
+ iconPath: icon.mdiAccountGroup,
+ accentClass: 'text-blue-500 bg-blue-50 dark:bg-blue-900/20',
+ },
+ {
+ key: 'roles',
+ permission: 'READ_ROLES',
+ label: 'Roles',
+ description: 'Hak akses aplikasi',
+ href: '/roles/roles-list',
+ iconPath: icon.mdiShieldAccountVariantOutline,
+ accentClass: 'text-indigo-500 bg-indigo-50 dark:bg-indigo-900/20',
+ },
+ {
+ key: 'permissions',
+ permission: 'READ_PERMISSIONS',
+ label: 'Permissions',
+ description: 'Kontrol izin detail',
+ href: '/permissions/permissions-list',
+ iconPath: icon.mdiShieldAccountOutline,
+ accentClass: 'text-slate-500 bg-slate-100 dark:bg-slate-800',
+ },
+ {
+ key: 'place_categories',
+ permission: 'READ_PLACE_CATEGORIES',
+ label: 'Kategori Tempat',
+ description: 'Kelompok data lokasi',
+ href: '/place_categories/place_categories-list',
+ iconPath: icon.mdiShape,
+ accentClass: 'text-violet-500 bg-violet-50 dark:bg-violet-900/20',
+ },
+ {
+ key: 'places',
+ permission: 'READ_PLACES',
+ label: 'Tempat',
+ description: 'Direktori lokasi GeoSeek',
+ href: '/places/places-list',
+ iconPath: icon.mdiMapMarker,
+ accentClass: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/20',
+ },
+ {
+ key: 'place_opening_hours',
+ permission: 'READ_PLACE_OPENING_HOURS',
+ label: 'Jam Buka',
+ description: 'Jadwal operasional tempat',
+ href: '/place_opening_hours/place_opening_hours-list',
+ iconPath: icon.mdiClockOutline,
+ accentClass: 'text-cyan-500 bg-cyan-50 dark:bg-cyan-900/20',
+ },
+ {
+ key: 'place_features',
+ permission: 'READ_PLACE_FEATURES',
+ label: 'Fitur Tempat',
+ description: 'Fasilitas dan atribut',
+ href: '/place_features/place_features-list',
+ iconPath: icon.mdiTagMultiple,
+ accentClass: 'text-teal-500 bg-teal-50 dark:bg-teal-900/20',
+ },
+ {
+ key: 'place_feature_links',
+ permission: 'READ_PLACE_FEATURE_LINKS',
+ label: 'Relasi Fitur',
+ description: 'Koneksi tempat dan fitur',
+ href: '/place_feature_links/place_feature_links-list',
+ iconPath: icon.mdiLinkVariant,
+ accentClass: 'text-sky-500 bg-sky-50 dark:bg-sky-900/20',
+ },
+ {
+ key: 'reviews',
+ permission: 'READ_REVIEWS',
+ label: 'Ulasan',
+ description: 'Komentar dan rating warga',
+ href: '/reviews/reviews-list',
+ iconPath: icon.mdiStarOutline,
+ accentClass: 'text-amber-500 bg-amber-50 dark:bg-amber-900/20',
+ },
+ {
+ key: 'favorites',
+ permission: 'READ_FAVORITES',
+ label: 'Favorit',
+ description: 'Tempat tersimpan anggota',
+ href: '/favorites/favorites-list',
+ iconPath: icon.mdiHeartOutline,
+ accentClass: 'text-rose-500 bg-rose-50 dark:bg-rose-900/20',
+ },
+ {
+ key: 'search_logs',
+ permission: 'READ_SEARCH_LOGS',
+ label: 'Riwayat Cari',
+ description: 'Aktivitas pencarian',
+ href: '/search_logs/search_logs-list',
+ iconPath: icon.mdiMagnify,
+ accentClass: 'text-blue-500 bg-blue-50 dark:bg-blue-900/20',
+ },
+ {
+ key: 'reports',
+ permission: 'READ_REPORTS',
+ label: 'Laporan',
+ description: 'Masukan dan aduan warga',
+ href: '/reports/reports-list',
+ iconPath: icon.mdiAlertCircleOutline,
+ accentClass: 'text-red-500 bg-red-50 dark:bg-red-900/20',
+ },
+] as const;
+
+type EntityKey = (typeof entityCards)[number]['key'];
+
+const initialCounts = entityCards.reduce(
+ (accumulator, item) => ({ ...accumulator, [item.key]: 'Loading...' }),
+ {} as Record
,
+);
+
+const adminShortcuts = [
+ {
+ label: 'Tambah Tempat',
+ description: 'Buat data lokasi baru untuk GeoSeek.',
+ href: '/places/places-new',
+ iconPath: icon.mdiPlusCircleOutline,
+ },
+ {
+ label: 'Kelola Review',
+ description: 'Moderasi ulasan dan rating anggota.',
+ href: '/reviews/reviews-list',
+ iconPath: icon.mdiCommentTextOutline,
+ },
+ {
+ label: 'Pantau Laporan',
+ description: 'Tindak lanjuti laporan dari anggota.',
+ href: '/reports/reports-list',
+ iconPath: icon.mdiClipboardAlertOutline,
+ },
+ {
+ label: 'Manajemen User',
+ description: 'Atur akun admin dan anggota.',
+ href: '/users/users-list',
+ iconPath: icon.mdiAccountGroup,
+ },
+ {
+ label: 'Atur Role',
+ description: 'Kelola akses Admin dan Anggota.',
+ href: '/roles/roles-list',
+ iconPath: icon.mdiShieldAccountVariantOutline,
+ },
+ {
+ label: 'Swagger API',
+ description: 'Buka dokumentasi endpoint backend.',
+ href: '/api-docs',
+ iconPath: icon.mdiFileCode,
+ },
+];
+
+const memberActions = [
+ {
+ label: 'Cari Tempat',
+ description: 'Temukan tempat, layanan, UMKM, dan promo sekitar.',
+ href: '/geoseek/search',
+ iconPath: icon.mdiMagnify,
+ colorClass: 'text-blue-600 bg-blue-50 border-blue-100 dark:bg-blue-900/20 dark:border-blue-800',
+ },
+ {
+ label: 'Lihat Peta',
+ description: 'Jelajahi lokasi dari tampilan peta GeoSeek.',
+ href: '/geoseek/map',
+ iconPath: icon.mdiMapMarkerRadiusOutline,
+ colorClass: 'text-emerald-600 bg-emerald-50 border-emerald-100 dark:bg-emerald-900/20 dark:border-emerald-800',
+ },
+ {
+ label: 'Favorit Saya',
+ description: 'Buka tempat yang sudah kamu simpan.',
+ href: '/favorites/favorites-list',
+ iconPath: icon.mdiHeartOutline,
+ colorClass: 'text-rose-600 bg-rose-50 border-rose-100 dark:bg-rose-900/20 dark:border-rose-800',
+ },
+ {
+ label: 'Buat Review',
+ description: 'Bagikan pengalaman untuk membantu warga lain.',
+ href: '/reviews/reviews-new',
+ iconPath: icon.mdiStarOutline,
+ colorClass: 'text-amber-600 bg-amber-50 border-amber-100 dark:bg-amber-900/20 dark:border-amber-800',
+ },
+ {
+ label: 'Kirim Laporan',
+ description: 'Laporkan info tempat yang kurang tepat.',
+ href: '/reports/reports-new',
+ iconPath: icon.mdiAlertCircleOutline,
+ colorClass: 'text-red-600 bg-red-50 border-red-100 dark:bg-red-900/20 dark:border-red-800',
+ },
+ {
+ label: 'Promo Terdekat',
+ description: 'Cek kabar promo dan penawaran menarik.',
+ href: '/geoseek/promos',
+ iconPath: icon.mdiTicketPercentOutline,
+ colorClass: 'text-orange-600 bg-orange-50 border-orange-100 dark:bg-orange-900/20 dark:border-orange-800',
+ },
+];
+
+const feedItems = [
+ {
+ username: 'Admin GeoSeek',
+ initials: 'AG',
+ timestamp: 'Baru saja',
+ badge: 'Laporan',
+ badgeClass: 'bg-red-500',
+ avatarClass: 'from-red-400 to-rose-500',
+ text: 'Ada laporan baru tentang jam buka tempat yang perlu diverifikasi oleh tim.',
+ mediaTitle: 'Update data lokasi',
+ mediaDescription: 'Prioritas tinggi untuk menjaga akurasi informasi warga.',
+ mediaClass: 'from-red-50 via-rose-50 to-orange-50 text-red-600 dark:from-red-950/40 dark:via-rose-950/30 dark:to-orange-950/30',
+ },
+ {
+ username: 'Warga Lokal',
+ initials: 'WL',
+ timestamp: '12 menit lalu',
+ badge: 'Warga',
+ badgeClass: 'bg-emerald-500',
+ avatarClass: 'from-emerald-400 to-teal-500',
+ text: 'Rekomendasi kuliner baru sudah ditambahkan. Jangan lupa simpan tempat favoritmu.',
+ mediaTitle: 'Rekomendasi komunitas',
+ mediaDescription: 'Bantu warga lain menemukan tempat terbaik di sekitar.',
+ mediaClass: 'from-emerald-50 via-teal-50 to-cyan-50 text-emerald-600 dark:from-emerald-950/40 dark:via-teal-950/30 dark:to-cyan-950/30',
+ },
+ {
+ username: 'Mitra Promo',
+ initials: 'MP',
+ timestamp: '1 jam lalu',
+ badge: 'Promo',
+ badgeClass: 'bg-amber-500',
+ avatarClass: 'from-amber-400 to-orange-500',
+ text: 'Promo akhir pekan tersedia untuk beberapa kategori UMKM dan kuliner.',
+ mediaTitle: 'Promo pilihan',
+ mediaDescription: 'Cek halaman promo untuk melihat penawaran terbaru.',
+ mediaClass: 'from-amber-50 via-orange-50 to-yellow-50 text-amber-700 dark:from-amber-950/40 dark:via-orange-950/30 dark:to-yellow-950/30',
+ },
+];
+
+type StatCardProps = {
+ label: string;
+ description: string;
+ value: CountValue;
+ href: string;
+ iconPath: string;
+ accentClass: string;
+ cardClass: string;
+};
+
+const StatCard = ({ label, description, value, href, iconPath, accentClass, cardClass }: StatCardProps) => (
+
+
+
+
+
{label}
+
{value}
+
{description}
+
+
+
+
+
+
+
+);
+
+type ShortcutCardProps = {
+ label: string;
+ description: string;
+ href: string;
+ iconPath: string;
+ cardClass: string;
+};
+
+const ShortcutCard = ({ label, description, href, iconPath, cardClass }: ShortcutCardProps) => (
+
+
+
+
+
+
{label}
+
{description}
+
+
+);
+
+type MemberActionCardProps = {
+ label: string;
+ description: string;
+ href: string;
+ iconPath: string;
+ colorClass: string;
+};
+
+const MemberActionCard = ({ label, description, href, iconPath, colorClass }: MemberActionCardProps) => (
+
+
+
+
{label}
+
{description}
+
+
+);
+
+type FeedItemProps = {
+ username: string;
+ initials: string;
+ timestamp: string;
+ badge: string;
+ badgeClass: string;
+ avatarClass: string;
+ text: string;
+ mediaTitle: string;
+ mediaDescription: string;
+ mediaClass: string;
+};
+
+const ActionButton = ({ iconPath, children }: { iconPath: string; children: ReactNode }) => (
+
+
+ {children}
+
+);
+
+const FeedItemCard = ({
+ username,
+ initials,
+ timestamp,
+ badge,
+ badgeClass,
+ avatarClass,
+ text,
+ mediaTitle,
+ mediaDescription,
+ mediaClass,
+}: FeedItemProps) => (
+
+
+
+ {initials}
+
+
+
{username}
+
{timestamp}
+
{badge}
+
+
+
+
+
{text}
+
+
+
+
+
{mediaTitle}
+
{mediaDescription}
+
+
+
+
+
+
+ Suka
+ Komentar
+ Bagikan
+
+
+);
+
+const formatRoleLabel = (roleName?: string) => {
+ if (!roleName || roleName === 'Registered User') {
+ return 'Anggota';
+ }
+
+ return roleName;
+};
+
const Dashboard = () => {
- const dispatch = useAppDispatch();
- const iconsColor = useAppSelector((state) => state.style.iconsColor);
- const corners = useAppSelector((state) => state.style.corners);
- const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
+ const dispatch = useAppDispatch();
+ const corners = useAppSelector((state) => state.style.corners);
+ const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const { isFetchingQuery } = useAppSelector((state) => state.openAi);
+ const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
- const loadingMessage = 'Loading...';
+ const [counts, setCounts] = React.useState>(initialCounts);
+ const [widgetsRole, setWidgetsRole] = React.useState({
+ role: { value: '', label: '' },
+ });
-
- const [users, setUsers] = React.useState(loadingMessage);
- const [roles, setRoles] = React.useState(loadingMessage);
- const [permissions, setPermissions] = React.useState(loadingMessage);
- const [place_categories, setPlace_categories] = React.useState(loadingMessage);
- const [places, setPlaces] = React.useState(loadingMessage);
- const [place_opening_hours, setPlace_opening_hours] = React.useState(loadingMessage);
- const [place_features, setPlace_features] = React.useState(loadingMessage);
- const [place_feature_links, setPlace_feature_links] = React.useState(loadingMessage);
- const [reviews, setReviews] = React.useState(loadingMessage);
- const [favorites, setFavorites] = React.useState(loadingMessage);
- const [search_logs, setSearch_logs] = React.useState(loadingMessage);
- const [reports, setReports] = React.useState(loadingMessage);
+ const cardClass = `${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700`;
+ const isAdminDashboard = hasPermission(currentUser, 'CREATE_ROLES');
+ const displayName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || currentUser?.email || 'Anggota';
+ const roleLabel = formatRoleLabel(currentUser?.app_role?.name);
-
- const [widgetsRole, setWidgetsRole] = React.useState({
- role: { value: '', label: '' },
+ async function loadData() {
+ if (!currentUser) return;
+
+ const requests = entityCards.map((item) => {
+ if (hasPermission(currentUser, item.permission)) {
+ return axios.get(`/${item.key}/count`);
+ }
+
+ return Promise.resolve({ data: { count: null } });
});
- const { currentUser } = useAppSelector((state) => state.auth);
- const { isFetchingQuery } = useAppSelector((state) => state.openAi);
-
- const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
-
-
- async function loadData() {
- const entities = ['users','roles','permissions','place_categories','places','place_opening_hours','place_features','place_feature_links','reviews','favorites','search_logs','reports',];
- const fns = [setUsers,setRoles,setPermissions,setPlace_categories,setPlaces,setPlace_opening_hours,setPlace_features,setPlace_feature_links,setReviews,setFavorites,setSearch_logs,setReports,];
- const requests = entities.map((entity, index) => {
-
- if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
- return axios.get(`/${entity.toLowerCase()}/count`);
- } else {
- fns[index](null);
- return Promise.resolve({data: {count: null}});
- }
-
- });
+ const results = await Promise.allSettled(requests);
- Promise.allSettled(requests).then((results) => {
- results.forEach((result, i) => {
- if (result.status === 'fulfilled') {
- fns[i](result.value.data.count);
- } else {
- fns[i](result.reason.message);
- }
- });
- });
- }
-
- async function getWidgets(roleId) {
- await dispatch(fetchWidgets(roleId));
- }
- React.useEffect(() => {
- if (!currentUser) return;
- loadData().then();
- setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
- }, [currentUser]);
+ setCounts((currentCounts) => {
+ const nextCounts = { ...currentCounts };
+
+ results.forEach((result, index) => {
+ const entityKey = entityCards[index].key;
+
+ if (result.status === 'fulfilled') {
+ nextCounts[entityKey] = result.value.data.count;
+ } else {
+ console.error(`Failed to load dashboard count for ${entityKey}:`, result.reason);
+ nextCounts[entityKey] = result.reason?.message || 'Error';
+ }
+ });
+
+ return nextCounts;
+ });
+ }
+
+ async function getWidgets(roleId: string) {
+ await dispatch(fetchWidgets(roleId));
+ }
+
+ React.useEffect(() => {
+ if (!currentUser) return;
+
+ loadData().catch((error) => {
+ console.error('Failed to load dashboard data:', error);
+ });
+ setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: formatRoleLabel(currentUser?.app_role?.name) } });
+ }, [currentUser]);
+
+ React.useEffect(() => {
+ if (!isAdminDashboard || !currentUser || !widgetsRole?.role?.value) return;
+
+ getWidgets(widgetsRole?.role?.value || '').catch((error) => {
+ console.error('Failed to load dashboard widgets:', error);
+ });
+ }, [isAdminDashboard, widgetsRole?.role?.value]);
+
+ const visibleAdminCards = entityCards.filter((item) => hasPermission(currentUser, item.permission));
+ const adminHighlights = entityCards.filter((item) => ['users', 'places', 'reviews', 'reports'].includes(item.key));
+ const memberSummary = entityCards.filter((item) => ['favorites', 'reviews', 'reports'].includes(item.key));
+
+ if (!currentUser) {
+ return (
+ <>
+
+ {getPageTitle('Dashboard')}
+
+
+
+ {''}
+
+ Memuat dashboard...
+
+ >
+ );
+ }
- React.useEffect(() => {
- if (!currentUser || !widgetsRole?.role?.value) return;
- getWidgets(widgetsRole?.role?.value || '').then();
- }, [widgetsRole?.role?.value]);
-
return (
<>
-
- {getPageTitle('Overview')}
-
+ {getPageTitle(isAdminDashboard ? 'Dashboard Admin' : 'Dashboard Anggota')}
+ icon={isAdminDashboard ? icon.mdiViewDashboardOutline : icon.mdiAccountHeartOutline}
+ title={isAdminDashboard ? 'Dashboard Admin' : 'Dashboard Anggota'}
+ main
+ >
{''}
-
- {hasPermission(currentUser, 'CREATE_ROLES') && }
- {!!rolesWidgets.length &&
- hasPermission(currentUser, 'CREATE_ROLES') && (
-
- {`${widgetsRole?.role?.label || 'Users'}'s widgets`}
-
+
+ {isAdminDashboard ? (
+ <>
+
+
+
+
GeoSeek Control Center
+
Selamat datang, {displayName}
+
+ Pantau data utama, moderasi laporan warga, dan kelola akses Admin/Anggota dari satu dashboard.
+
+
+
+
Role aktif
+
{roleLabel}
+
+
+
+
+
+ {adminHighlights.map((item) => (
+
+ ))}
+
+
+ {hasPermission(currentUser, 'CREATE_ROLES') && (
+
)}
-
- {(isFetchingQuery || loading) && (
-
- {' '}
- Loading widgets...
-
+ {!!rolesWidgets.length && hasPermission(currentUser, 'CREATE_ROLES') && (
+
{`${widgetsRole?.role?.label || 'Users'} widgets`}
)}
- { rolesWidgets &&
- rolesWidgets.map((widget) => (
-
- ))}
-
+
+ {(isFetchingQuery || loading) && (
+
+ Loading widgets...
+
+ )}
- {!!rolesWidgets.length &&
}
-
-
-
-
- {hasPermission(currentUser, 'READ_USERS') &&
-
-
-
-
- Users
-
-
- {users}
-
-
-
-
-
-
+ {rolesWidgets?.map((widget) => (
+
+ ))}
+
+
+ {!!rolesWidgets.length &&
}
+
+
+
+
+
Ringkasan Semua Data
+
+
+ {visibleAdminCards.map((item) => (
+
+ ))}
+
+
+
+
+
+
+
Aksi Cepat Admin
+
+
+ {adminShortcuts.map((item) => (
+
+ ))}
+
+
+ >
+ ) : (
+
+
+
+
+
GeoSeek Anggota
+
Halo, {displayName}
+
+ Temukan tempat terbaik, simpan favorit, tulis review, dan kirim laporan untuk membantu warga lain.
+
- }
-
- {hasPermission(currentUser, 'READ_ROLES') &&
-
-
-
-
- Roles
-
-
- {roles}
-
-
-
-
-
-
+
+
Status akun
+
{roleLabel}
- }
-
- {hasPermission(currentUser, 'READ_PERMISSIONS') &&
-
-
-
-
- Permissions
-
-
- {permissions}
-
-
-
-
-
+
+
+
+
+ {memberSummary.map((item) => (
+
+
+
+
+
{item.label}
+
{counts[item.key]}
+
+
-
- }
-
- {hasPermission(currentUser, 'READ_PLACE_CATEGORIES') &&
-
-
-
-
- Place categories
-
-
- {place_categories}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_PLACES') &&
-
-
-
-
- Places
-
-
- {places}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_PLACE_OPENING_HOURS') &&
-
-
-
-
- Place opening hours
-
-
- {place_opening_hours}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_PLACE_FEATURES') &&
-
-
-
-
- Place features
-
-
- {place_features}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_PLACE_FEATURE_LINKS') &&
-
-
-
-
- Place feature links
-
-
- {place_feature_links}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_REVIEWS') &&
-
-
-
-
- Reviews
-
-
- {reviews}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_FAVORITES') &&
-
-
-
-
- Favorites
-
-
- {favorites}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_SEARCH_LOGS') &&
-
-
-
-
- Search logs
-
-
- {search_logs}
-
-
-
-
-
-
-
- }
-
- {hasPermission(currentUser, 'READ_REPORTS') &&
-
-
-
-
- Reports
-
-
- {reports}
-
-
-
-
-
-
-
- }
-
-
-
+
+
+ ))}
+
+
+
+
+
+
Mulai Jelajah
+
+
+ {memberActions.map((item) => (
+
+ ))}
+
+
+
+
+
+
+
Feed Aktivitas
+
+
+ {feedItems.map((item) => (
+
+ ))}
+
+
+ )}
>
- )
-}
+ );
+};
Dashboard.getLayout = function getLayout(page: ReactElement) {
- return {page}
-}
+ return {page} ;
+};
-export default Dashboard
+export default Dashboard;
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 5e01481..1b82df2 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -16,7 +16,14 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
-import { findMe, loginUser, resetAction } from '../stores/authSlice';
+import {
+ findMe,
+ loginUser,
+ requestEmailOtp,
+ resetAction,
+ setAuthToken,
+ verifyEmailOtp,
+} from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
@@ -37,7 +44,17 @@ export default function Login() {
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const [showPassword, setShowPassword] = useState(false);
- const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
+ const [otpEmail, setOtpEmail] = useState('');
+ const [otpCode, setOtpCode] = useState('');
+ const [otpRequested, setOtpRequested] = useState(false);
+ const {
+ currentUser,
+ isFetching,
+ isRequestingOtp,
+ errorMessage,
+ token,
+ notify:notifyState,
+ } = useAppSelector(
(state) => state.auth,
);
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
@@ -56,6 +73,16 @@ export default function Login() {
}
fetchData();
}, []);
+ // Handle Google OAuth callback token
+ useEffect(() => {
+ const redirectToken = router.query.token;
+
+ if (typeof redirectToken === 'string') {
+ dispatch(setAuthToken(redirectToken));
+ router.replace('/login', undefined, { shallow: true });
+ }
+ }, [dispatch, router, router.query.token]);
+
// Fetch user data
useEffect(() => {
if (token) {
@@ -92,6 +119,52 @@ export default function Login() {
await dispatch(loginUser(rest));
};
+ const handleGoogleSignin = () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.location.href = `/api/auth/signin/google?app=${encodeURIComponent(window.location.origin)}`;
+ };
+
+ const handleRequestOtp = async () => {
+ const email = otpEmail.trim();
+
+ if (!email) {
+ notify('error', 'Masukkan email terlebih dahulu');
+ return;
+ }
+
+ const result = await dispatch(requestEmailOtp({ email }));
+
+ if (requestEmailOtp.fulfilled.match(result)) {
+ setOtpRequested(true);
+ notify('success', 'Kode OTP sudah dikirim ke email Anda');
+ return;
+ }
+
+ notify('error', String(result.payload || 'Gagal mengirim OTP'));
+ };
+
+ const handleVerifyOtp = async () => {
+ const email = otpEmail.trim();
+ const otp = otpCode.trim();
+
+ if (!email || !otp) {
+ notify('error', 'Masukkan email dan kode OTP');
+ return;
+ }
+
+ const result = await dispatch(verifyEmailOtp({ email, otp }));
+
+ if (verifyEmailOtp.fulfilled.match(result)) {
+ notify('success', 'OTP berhasil diverifikasi');
+ return;
+ }
+
+ notify('error', String(result.payload || 'Kode OTP tidak valid'));
+ };
+
const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({
...prev,
@@ -196,6 +269,62 @@ export default function Login() {
+
+
Masuk / Daftar Anggota
+
+ User baru dari Google atau Email OTP otomatis dibuat sebagai Anggota. Admin tetap memakai role admin jika emailnya sudah terdaftar sebagai admin.
+
+
+
+
+
+
+
+ setOtpEmail(event.target.value)}
+ placeholder='nama@email.com'
+ />
+
+ {otpRequested && (
+
+ setOtpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
+ placeholder='123456'
+ />
+
+ )}
+
+
+ {otpRequested && (
+
+ )}
+
+
+
+
+
Atau masuk dengan password admin/user yang sudah ada.
+
state.auth);
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
+ React.useEffect(() => {
+ const redirectToken = router.query.token;
+
+ if (typeof redirectToken === 'string') {
+ dispatch(setAuthToken(redirectToken));
+ router.replace('/dashboard');
+ }
+ }, [dispatch, router, router.query.token]);
+
+ const handleGoogleSignin = () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.location.href = `/api/auth/signin/google?app=${encodeURIComponent(window.location.origin)}`;
+ };
+
+ const handleRequestOtp = async () => {
+ const email = otpEmail.trim();
+
+ if (!email) {
+ notify('error', 'Masukkan email terlebih dahulu');
+ return;
+ }
+
+ const result = await dispatch(requestEmailOtp({ email }));
+
+ if (requestEmailOtp.fulfilled.match(result)) {
+ setOtpRequested(true);
+ notify('success', 'Kode OTP sudah dikirim ke email Anda');
+ return;
+ }
+
+ notify('error', String(result.payload || 'Gagal mengirim OTP'));
+ };
+
+ const handleVerifyOtp = async () => {
+ const email = otpEmail.trim();
+ const otp = otpCode.trim();
+
+ if (!email || !otp) {
+ notify('error', 'Masukkan email dan kode OTP');
+ return;
+ }
+
+ const result = await dispatch(verifyEmailOtp({ email, otp }));
+
+ if (verifyEmailOtp.fulfilled.match(result)) {
+ notify('success', 'Akun Anggota berhasil dibuat/masuk');
+ await router.push('/dashboard');
+ return;
+ }
+
+ notify('error', String(result.payload || 'Kode OTP tidak valid'));
+ };
const handleSubmit = async (value) => {
setLoading(true)
@@ -39,11 +101,67 @@ export default function Register() {
return (
<>
- {getPageTitle('Login')}
+ {getPageTitle('Register')}
+
+
Daftar Anggota
+
+ Pendaftar baru lewat Google atau Email OTP otomatis mendapat role Anggota. Admin dibuat/diubah manual oleh admin utama.
+
+
+
+
+
+
+
+ setOtpEmail(event.target.value)}
+ placeholder='nama@email.com'
+ />
+
+ {otpRequested && (
+
+ setOtpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
+ placeholder='123456'
+ />
+
+ )}
+
+
+ {otpRequested && (
+
+ )}
+
+
+
+
+
Atau gunakan form password legacy di bawah ini.
+
('auth/setAuthToken')
export const loginUser = createAsyncThunk(
'auth/loginUser',
@@ -40,6 +43,36 @@ export const loginUser = createAsyncThunk(
}
);
+export const requestEmailOtp = createAsyncThunk(
+ 'auth/requestEmailOtp',
+ async (payload: { email: string }, { rejectWithValue }) => {
+ try {
+ const response = await axios.post('/auth/otp/request', payload);
+ return response.data;
+ } catch (error) {
+ if (!error.response) {
+ throw error;
+ }
+ return rejectWithValue(error.response.data);
+ }
+ }
+);
+
+export const verifyEmailOtp = createAsyncThunk(
+ 'auth/verifyEmailOtp',
+ async (payload: { email: string; otp: string }, { rejectWithValue }) => {
+ try {
+ const response = await axios.post('/auth/otp/verify', payload);
+ return response.data;
+ } catch (error) {
+ if (!error.response) {
+ throw error;
+ }
+ return rejectWithValue(error.response.data);
+ }
+ }
+);
+
export const passwordReset = createAsyncThunk(
'auth/passwordReset',
async (value: Record, { rejectWithValue }) => {
@@ -66,6 +99,17 @@ export const findMe = createAsyncThunk('auth/findMe', async () => {
return response.data;
});
+const persistAuthToken = (state: MainState, token: string) => {
+ const user = jwt.decode(token);
+
+ state.errorMessage = '';
+ state.token = token;
+ state.isFetching = false;
+ localStorage.setItem('token', token);
+ localStorage.setItem('user', JSON.stringify(user));
+ axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
+};
+
export const authSlice = createSlice({
name: 'auth',
initialState,
@@ -83,20 +127,42 @@ export const authSlice = createSlice({
state.isFetching = true;
});
builder.addCase(loginUser.fulfilled, (state, action) => {
- const token = action.payload;
- const user = jwt.decode(token);
-
- state.errorMessage = '';
- state.token = token;
- localStorage.setItem('token', token);
- localStorage.setItem('user', JSON.stringify(user));
- axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
+ persistAuthToken(state, action.payload);
});
builder.addCase(loginUser.rejected, (state, action) => {
state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
state.isFetching = false;
});
+
+ builder.addCase(requestEmailOtp.pending, (state) => {
+ state.isRequestingOtp = true;
+ state.errorMessage = '';
+ });
+ builder.addCase(requestEmailOtp.fulfilled, (state) => {
+ state.isRequestingOtp = false;
+ state.errorMessage = '';
+ });
+ builder.addCase(requestEmailOtp.rejected, (state, action) => {
+ state.errorMessage = String(action.payload) || 'Failed to send OTP';
+ state.isRequestingOtp = false;
+ });
+
+ builder.addCase(verifyEmailOtp.pending, (state) => {
+ state.isFetching = true;
+ state.errorMessage = '';
+ });
+ builder.addCase(verifyEmailOtp.fulfilled, (state, action) => {
+ persistAuthToken(state, action.payload);
+ });
+ builder.addCase(verifyEmailOtp.rejected, (state, action) => {
+ state.errorMessage = String(action.payload) || 'Failed to verify OTP';
+ state.isFetching = false;
+ });
+
+ builder.addCase(setAuthToken, (state, action) => {
+ persistAuthToken(state, action.payload);
+ });
builder.addCase(findMe.pending, () => {
console.log('Pending findMe');
});