Autosave: 20260618-104218
This commit is contained in:
parent
39d56bcef3
commit
1c09ee751e
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
58
backend/src/services/email/list/emailOtp.js
Normal file
58
backend/src/services/email/list/emailOtp.js
Normal file
@ -0,0 +1,58 @@
|
||||
const { getNotification } = require('../../notifications/helpers');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.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 `
|
||||
<!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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user