Autosave: 20260618-104218

This commit is contained in:
Flatlogic Bot 2026-06-18 10:42:13 +00:00
parent 39d56bcef3
commit 1c09ee751e
12 changed files with 1760 additions and 501 deletions

View File

@ -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));
}

View File

@ -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;
}
},
};

View File

@ -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);
});

View File

@ -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;

View File

@ -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) {

View File

@ -0,0 +1,58 @@
const { getNotification } = require('../../notifications/helpers');
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 `
<!DOCTYPE html>
<html>
<head>
<style>
.email-container { max-width: 600px; margin: auto; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; font-family: Arial, sans-serif; }
.email-header { background: #3498db; color: #ffffff; padding: 16px; text-align: center; font-size: 18px; font-weight: bold; }
.email-body { padding: 20px; color: #1f2937; }
.otp-code { display: inline-block; letter-spacing: 6px; font-size: 30px; font-weight: bold; background: #f3f4f6; border-radius: 8px; padding: 12px 18px; margin: 12px 0; }
.email-footer { padding: 16px; background: #f7fafc; text-align: center; color: #4a5568; font-size: 14px; }
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">Kode OTP untuk ${appTitle}</div>
<div class="email-body">
<p>Halo,</p>
<p>Gunakan kode berikut untuk masuk atau daftar sebagai Anggota:</p>
<div class="otp-code">${otp}</div>
<p>Kode ini berlaku selama ${expiresInMinutes} menit.</p>
<p>Jika Anda tidak meminta kode ini, abaikan email ini.</p>
</div>
<div class="email-footer">
Terima kasih,<br />Tim ${appTitle}
</div>
</div>
</body>
</html>
`;
}
};

View File

@ -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: {

View File

@ -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<GeoSeekItemType, string> = {
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<Record<GeoSeekModuleKey, string>> = {
home: 'produk jasa umkm lokal',
search: 'produk jasa umkm lokal',
@ -189,6 +206,120 @@ const apiQueryByModule: Partial<Record<GeoSeekModuleKey, string>> = {
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<AiLocationPlan | null>(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<GeoSeekItem[]>([]);
@ -700,6 +835,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) {
const [apiError, setApiError] = useState('');
const [apiMeta, setApiMeta] = useState<GeoSeekApiMeta>(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) {
)}
</CardBox>
<CardBox className="mb-6 overflow-hidden border border-blue-100 bg-gradient-to-br from-white via-blue-50 to-emerald-50 dark:border-blue-900/40 dark:from-dark-900 dark:via-blue-950/30 dark:to-emerald-950/20">
<div className="mb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm font-bold uppercase tracking-[0.25em] text-blue-600 dark:text-blue-300">AI Pencari Lokasi</p>
<h3 className="mt-2 text-2xl font-black text-gray-900 dark:text-white">Cari tempat dengan bahasa bebas</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
Tulis kebutuhan seperti cari ATM terdekat atau apotek buka sekarang. AI akan mengubahnya menjadi keyword dan radius, lalu GeoSeek menjalankan pencarian lokasi publik.
</p>
</div>
<div className="rounded-2xl bg-blue-600 p-3 text-white shadow-lg shadow-blue-600/20">
<BaseIcon path={icon.mdiRobot} size={28} />
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_260px]">
<label className="block">
<span className="mb-2 block text-sm font-bold text-gray-700 dark:text-gray-200">Perintah AI</span>
<textarea
value={aiLocationInput}
onChange={(event) => setAiLocationInput(event.target.value)}
placeholder="Contoh: cari ATM terdekat dari lokasi saya"
className="h-28 w-full rounded-2xl border border-blue-100 bg-white p-4 text-sm text-gray-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
/>
</label>
<div className="flex flex-col justify-end gap-3">
<BaseButton
color="info"
label={isAiLocationLoading ? 'AI memproses...' : 'Cari dengan AI'}
icon={icon.mdiMapSearchOutline}
className="h-12 w-full"
disabled={isAiLocationLoading}
onClick={() => {
runAiLocationFinder();
}}
/>
<p className="rounded-2xl bg-white/80 p-3 text-xs leading-5 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
Aman: AI hanya mencari lokasi/tempat/bisnis publik, bukan melacak orang pribadi, chat, atau akun private.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{aiLocationExamples.map((example) => (
<button
key={example}
type="button"
className="rounded-full bg-white px-3 py-1.5 text-xs font-semibold text-gray-600 shadow-sm transition hover:bg-blue-600 hover:text-white dark:bg-dark-800 dark:text-gray-300"
onClick={() => setAiLocationInput(example)}
>
{example}
</button>
))}
</div>
<div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-2xl bg-white/80 p-4 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300 md:col-span-2">
<strong className="text-gray-900 dark:text-white">Status AI:</strong> {aiLocationStatus}
</div>
<div className="rounded-2xl bg-white/80 p-4 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
<strong className="text-gray-900 dark:text-white">Rencana:</strong>{' '}
{aiLocationPlan?.allowed
? `${aiLocationPlan.keyword}${aiLocationPlan.radiusKm} km`
: aiLocationPlan?.safetyNote || 'Belum ada rencana AI'}
</div>
</div>
</CardBox>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard label="Hasil Prioritas" value={results.length} help="Diurutkan dari jarak terdekat" iconPath={icon.mdiMagnify} />
<StatCard label="Rata-rata GeoScore" value={insight.averageGeoScore} help="Dari modul aktif" iconPath={icon.mdiChartTimelineVariant} />

File diff suppressed because it is too large Load Diff

View File

@ -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() {
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<div className='mb-6 rounded-xl border border-blue-100 bg-blue-50/70 p-4 dark:border-dark-700 dark:bg-dark-800'>
<h3 className='mb-2 text-lg font-semibold text-gray-800 dark:text-white'>Masuk / Daftar Anggota</h3>
<p className='mb-4 text-sm text-gray-600 dark:text-gray-300'>
User baru dari Google atau Email OTP otomatis dibuat sebagai Anggota. Admin tetap memakai role admin jika emailnya sudah terdaftar sebagai admin.
</p>
<BaseButtons type='justify-start' mb='mb-4'>
<BaseButton
className='w-full'
label='Lanjutkan dengan Google'
color='info'
onClick={handleGoogleSignin}
/>
</BaseButtons>
<div className='rounded-lg bg-white/80 p-4 dark:bg-dark-900'>
<FormField label='Email OTP' help='Masukkan email untuk menerima kode OTP'>
<input
type='email'
value={otpEmail}
onChange={(event) => setOtpEmail(event.target.value)}
placeholder='nama@email.com'
/>
</FormField>
{otpRequested && (
<FormField label='Kode OTP' help='Masukkan 6 digit kode dari email'>
<input
inputMode='numeric'
maxLength={6}
value={otpCode}
onChange={(event) => setOtpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder='123456'
/>
</FormField>
)}
<BaseButtons type='justify-start'>
<BaseButton
label={isRequestingOtp ? 'Mengirim...' : otpRequested ? 'Kirim Ulang OTP' : 'Kirim OTP'}
color='info'
outline
disabled={isRequestingOtp}
onClick={handleRequestOtp}
/>
{otpRequested && (
<BaseButton
label={isFetching ? 'Memverifikasi...' : 'Verifikasi OTP'}
color='success'
disabled={isFetching}
onClick={handleVerifyOtp}
/>
)}
</BaseButtons>
</div>
<BaseDivider />
<p className='text-center text-xs text-gray-500'>Atau masuk dengan password admin/user yang sudah ada.</p>
</div>
<Formik
initialValues={initialValues}
enableReinitialize

View File

@ -12,14 +12,76 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import { requestEmailOtp, setAuthToken, verifyEmailOtp } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import axios from "axios";
export default function Register() {
const [loading, setLoading] = React.useState(false);
const [otpEmail, setOtpEmail] = React.useState('');
const [otpCode, setOtpCode] = React.useState('');
const [otpRequested, setOtpRequested] = React.useState(false);
const router = useRouter();
const dispatch = useAppDispatch();
const { isFetching, isRequestingOtp } = useAppSelector((state) => 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 (
<>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('Register')}</title>
</Head>
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<div className='mb-6 rounded-xl border border-blue-100 bg-blue-50/70 p-4 dark:border-dark-700 dark:bg-dark-800'>
<h2 className='mb-2 text-2xl font-semibold text-gray-800 dark:text-white'>Daftar Anggota</h2>
<p className='mb-4 text-sm text-gray-600 dark:text-gray-300'>
Pendaftar baru lewat Google atau Email OTP otomatis mendapat role Anggota. Admin dibuat/diubah manual oleh admin utama.
</p>
<BaseButtons type='justify-start' mb='mb-4'>
<BaseButton
className='w-full'
label='Daftar / Masuk dengan Google'
color='info'
onClick={handleGoogleSignin}
/>
</BaseButtons>
<div className='rounded-lg bg-white/80 p-4 dark:bg-dark-900'>
<FormField label='Email OTP' help='Masukkan email untuk menerima kode OTP'>
<input
type='email'
value={otpEmail}
onChange={(event) => setOtpEmail(event.target.value)}
placeholder='nama@email.com'
/>
</FormField>
{otpRequested && (
<FormField label='Kode OTP' help='Masukkan 6 digit kode dari email'>
<input
inputMode='numeric'
maxLength={6}
value={otpCode}
onChange={(event) => setOtpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder='123456'
/>
</FormField>
)}
<BaseButtons type='justify-start'>
<BaseButton
label={isRequestingOtp ? 'Mengirim...' : otpRequested ? 'Kirim Ulang OTP' : 'Kirim OTP'}
color='info'
outline
disabled={isRequestingOtp}
onClick={handleRequestOtp}
/>
{otpRequested && (
<BaseButton
label={isFetching ? 'Memverifikasi...' : 'Verifikasi OTP'}
color='success'
disabled={isFetching}
onClick={handleVerifyOtp}
/>
)}
</BaseButtons>
</div>
<BaseDivider />
<p className='text-center text-xs text-gray-500'>Atau gunakan form password legacy di bawah ini.</p>
</div>
<Formik
initialValues={{
email: '',

View File

@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
interface MainState {
isFetching: boolean;
isRequestingOtp: boolean;
errorMessage: string;
currentUser: any;
notify: any;
@ -13,6 +14,7 @@ interface MainState {
const initialState: MainState = {
/* User */
isFetching: false,
isRequestingOtp: false,
errorMessage: '',
currentUser: null,
token: '',
@ -24,6 +26,7 @@ const initialState: MainState = {
};
export const resetAction = createAction('auth/passwordReset/reset')
export const setAuthToken = createAction<string>('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<string, string>, { 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');
});