Revamp stage one - individual account and account setup fixed
This commit is contained in:
@@ -48,6 +48,7 @@ public class SecurityConfig {
|
||||
.pathMatchers("/api/kifi-v2/auth/**").permitAll()
|
||||
.pathMatchers("/api/kifi-v2/health/**").permitAll()
|
||||
.pathMatchers("/api/kifi-v2/wallets/test/**").permitAll()
|
||||
.pathMatchers("/public/legal/**").permitAll()
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/public/legal")
|
||||
public class LegalController {
|
||||
|
||||
@GetMapping(value = "/terms", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public Mono<ResponseEntity<String>> getTerms() {
|
||||
return loadResource("policies/terms-and-conditions.html");
|
||||
}
|
||||
|
||||
@GetMapping(value = "/privacy", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public Mono<ResponseEntity<String>> getPrivacy() {
|
||||
return loadResource("policies/privacy-policy.html");
|
||||
}
|
||||
|
||||
private Mono<ResponseEntity<String>> loadResource(String path) {
|
||||
return Mono.fromCallable(() -> {
|
||||
ClassPathResource resource = new ClassPathResource(path);
|
||||
if (resource.exists()) {
|
||||
String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(content);
|
||||
}
|
||||
return ResponseEntity.notFound().build();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.kifi.api.controller;
|
||||
|
||||
import com.kifi.api.dto.SetupRequestDTO;
|
||||
import com.kifi.api.service.SetupService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/kifi-v2/account")
|
||||
@RequiredArgsConstructor
|
||||
public class SetupController {
|
||||
|
||||
private final SetupService setupService;
|
||||
|
||||
@GetMapping("/setup/status")
|
||||
public Mono<ResponseEntity<Map<String, String>>> getSetupStatus(Authentication authentication) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
return setupService.getSetupStatus(userId)
|
||||
.map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
@GetMapping("/username/availability")
|
||||
public Mono<ResponseEntity<Map<String, Boolean>>> isUsernameAvailable(@RequestParam String username) {
|
||||
return setupService.isUsernameAvailable(username)
|
||||
.map(available -> ResponseEntity.ok(Map.of("available", available)));
|
||||
}
|
||||
|
||||
@PostMapping("/setup")
|
||||
public Mono<ResponseEntity<Map<String, String>>> completeSetup(Authentication authentication, @RequestBody SetupRequestDTO request, org.springframework.web.server.ServerWebExchange exchange) {
|
||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||
String userAgent = exchange.getRequest().getHeaders().getFirst("User-Agent");
|
||||
String ipAddress = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
|
||||
if (ipAddress == null && exchange.getRequest().getRemoteAddress() != null) {
|
||||
ipAddress = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
return setupService.completeSetup(userId, request, ipAddress, userAgent)
|
||||
.map(message -> ResponseEntity.ok(Map.of("message", message)))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("message", e.getMessage()))));
|
||||
}
|
||||
}
|
||||
23
kifi-api/src/main/java/com/kifi/api/dto/SetupRequestDTO.java
Normal file
23
kifi-api/src/main/java/com/kifi/api/dto/SetupRequestDTO.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.kifi.api.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SetupRequestDTO {
|
||||
private String accountType;
|
||||
private String name;
|
||||
private String username;
|
||||
private String mobileNumber;
|
||||
|
||||
private String natureOfBusiness;
|
||||
private String businessName;
|
||||
private String address;
|
||||
private Long stateId;
|
||||
private String emailId;
|
||||
private String gstin;
|
||||
private String panNumber;
|
||||
private String msmeNumber;
|
||||
|
||||
private Boolean termsAccepted;
|
||||
private Boolean privacyAccepted;
|
||||
}
|
||||
@@ -23,6 +23,12 @@ public class User {
|
||||
private Boolean enabled = false;
|
||||
@Builder.Default
|
||||
private String profileType = "INDIVIDUAL";
|
||||
private String username;
|
||||
private String name;
|
||||
private String mobileNumber;
|
||||
@Builder.Default
|
||||
private String setupStatus = "NOT_STARTED";
|
||||
private LocalDateTime setupCompletedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
29
kifi-api/src/main/java/com/kifi/api/entity/UserConsent.java
Normal file
29
kifi-api/src/main/java/com/kifi/api/entity/UserConsent.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.kifi.api.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("user_consents")
|
||||
public class UserConsent {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String consentType;
|
||||
private String policyVersion;
|
||||
@Builder.Default
|
||||
private Boolean accepted = true;
|
||||
private LocalDateTime acceptedAt;
|
||||
private String ipAddress;
|
||||
private String userAgent;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ public class BusinessProfile {
|
||||
private String emailId;
|
||||
private String panNumber;
|
||||
private String gstin;
|
||||
private String natureOfBusiness;
|
||||
private String msmeNumber;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kifi.api.repository;
|
||||
|
||||
import com.kifi.api.entity.UserConsent;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface UserConsentRepository extends ReactiveCrudRepository<UserConsent, Long> {
|
||||
Mono<UserConsent> findByUserIdAndConsentTypeAndPolicyVersion(Long userId, String consentType, String policyVersion);
|
||||
}
|
||||
@@ -11,4 +11,7 @@ import reactor.core.publisher.Mono;
|
||||
public interface UserRepository extends R2dbcRepository<User, Long> {
|
||||
Mono<User> findByEmail(String email);
|
||||
Flux<User> findByEmailContainingIgnoreCase(String email);
|
||||
Mono<User> findByUsername(String username);
|
||||
|
||||
Mono<User> findByEmailOrUsername(String email, String username);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public class AuthService {
|
||||
String email = decryptField(request.getEmail());
|
||||
String password = decryptField(request.getPassword());
|
||||
|
||||
return userRepository.findByEmail(email)
|
||||
return userRepository.findByEmailOrUsername(email, email)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("Invalid email or password")))
|
||||
.flatMap(user -> {
|
||||
if (!user.getEnabled()) {
|
||||
|
||||
@@ -41,7 +41,7 @@ public class CryptoService {
|
||||
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||
return new String(decryptedBytes, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to decrypt RSA payload", e);
|
||||
log.warn("Failed to decrypt RSA payload: {}", e.getMessage());
|
||||
throw new RuntimeException("Failed to decrypt payload");
|
||||
}
|
||||
}
|
||||
|
||||
114
kifi-api/src/main/java/com/kifi/api/service/SetupService.java
Normal file
114
kifi-api/src/main/java/com/kifi/api/service/SetupService.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.kifi.api.service;
|
||||
|
||||
import com.kifi.api.dto.SetupRequestDTO;
|
||||
import com.kifi.api.entity.User;
|
||||
import com.kifi.api.entity.UserConsent;
|
||||
import com.kifi.api.entity.business.BusinessProfile;
|
||||
import com.kifi.api.repository.UserConsentRepository;
|
||||
import com.kifi.api.repository.UserRepository;
|
||||
import com.kifi.api.repository.business.BusinessProfileRepository;
|
||||
import com.kifi.api.util.CryptoUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SetupService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BusinessProfileRepository businessProfileRepository;
|
||||
private final UserConsentRepository userConsentRepository;
|
||||
private final CryptoUtils cryptoUtils;
|
||||
|
||||
public Mono<java.util.Map<String, String>> getSetupStatus(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.map(u -> {
|
||||
java.util.Map<String, String> map = new java.util.HashMap<>();
|
||||
map.put("status", u.getSetupStatus() != null ? u.getSetupStatus() : "NOT_STARTED");
|
||||
map.put("profileType", u.getProfileType() != null ? u.getProfileType() : "INDIVIDUAL");
|
||||
map.put("name", u.getName() != null ? u.getName() : "");
|
||||
map.put("email", u.getEmail() != null ? u.getEmail() : "");
|
||||
return map;
|
||||
})
|
||||
.defaultIfEmpty(java.util.Map.of("status", "NOT_STARTED", "profileType", "INDIVIDUAL"));
|
||||
}
|
||||
|
||||
public Mono<Boolean> isUsernameAvailable(String username) {
|
||||
return userRepository.findByUsername(username)
|
||||
.hasElement()
|
||||
.map(exists -> !exists);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<String> completeSetup(Long userId, SetupRequestDTO request, String ipAddress, String userAgent) {
|
||||
return userRepository.findById(userId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("User not found")))
|
||||
.flatMap(user -> {
|
||||
user.setUsername(request.getUsername());
|
||||
user.setName(request.getName());
|
||||
user.setMobileNumber(cryptoUtils.encrypt(request.getMobileNumber()));
|
||||
user.setProfileType(request.getAccountType());
|
||||
user.setSetupStatus("COMPLETED");
|
||||
user.setSetupCompletedAt(LocalDateTime.now());
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
Mono<User> userSaveMono = userRepository.save(user);
|
||||
|
||||
Mono<BusinessProfile> businessProfileMono;
|
||||
if ("BUSINESS".equalsIgnoreCase(request.getAccountType())) {
|
||||
businessProfileMono = businessProfileRepository.findByUserId(userId)
|
||||
.defaultIfEmpty(BusinessProfile.builder().userId(userId).build())
|
||||
.flatMap(bp -> {
|
||||
bp.setBusinessName(request.getBusinessName() != null ? request.getBusinessName() : request.getName());
|
||||
bp.setNatureOfBusiness(request.getNatureOfBusiness());
|
||||
bp.setAddress(request.getAddress());
|
||||
bp.setStateId(request.getStateId());
|
||||
bp.setEmailId(request.getEmailId());
|
||||
bp.setGstin(cryptoUtils.encrypt(request.getGstin()));
|
||||
bp.setPanNumber(cryptoUtils.encrypt(request.getPanNumber()));
|
||||
bp.setMsmeNumber(cryptoUtils.encrypt(request.getMsmeNumber()));
|
||||
bp.setCreatedAt(bp.getCreatedAt() == null ? LocalDateTime.now() : bp.getCreatedAt());
|
||||
bp.setUpdatedAt(LocalDateTime.now());
|
||||
return businessProfileRepository.save(bp);
|
||||
});
|
||||
} else {
|
||||
businessProfileMono = Mono.just(new BusinessProfile());
|
||||
}
|
||||
|
||||
Mono<UserConsent> termsConsent = userConsentRepository.save(
|
||||
UserConsent.builder()
|
||||
.userId(userId)
|
||||
.consentType("TERMS_AND_CONDITIONS")
|
||||
.policyVersion("1.0")
|
||||
.accepted(request.getTermsAccepted())
|
||||
.acceptedAt(LocalDateTime.now())
|
||||
.ipAddress(ipAddress)
|
||||
.userAgent(userAgent)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build()
|
||||
);
|
||||
|
||||
Mono<UserConsent> privacyConsent = userConsentRepository.save(
|
||||
UserConsent.builder()
|
||||
.userId(userId)
|
||||
.consentType("PRIVACY_POLICY")
|
||||
.policyVersion("1.0")
|
||||
.accepted(request.getPrivacyAccepted())
|
||||
.acceptedAt(LocalDateTime.now())
|
||||
.ipAddress(ipAddress)
|
||||
.userAgent(userAgent)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build()
|
||||
);
|
||||
|
||||
return Mono.zip(userSaveMono, businessProfileMono, termsConsent, privacyConsent)
|
||||
.map(tuple -> "Setup completed successfully");
|
||||
});
|
||||
}
|
||||
}
|
||||
46
kifi-api/src/main/java/com/kifi/api/util/CryptoUtils.java
Normal file
46
kifi-api/src/main/java/com/kifi/api/util/CryptoUtils.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.kifi.api.util;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class CryptoUtils {
|
||||
|
||||
private final String secret;
|
||||
|
||||
public CryptoUtils(@Value("${app.encryption.secret:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}") String secret) {
|
||||
// Ensure the secret is at least 16 bytes for AES
|
||||
this.secret = secret.substring(0, 16);
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
if (plainText == null) return null;
|
||||
try {
|
||||
SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES");
|
||||
Cipher cipher = Cipher.getInstance("AES");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes());
|
||||
return Base64.getEncoder().encodeToString(encryptedBytes);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error encrypting data", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedText) {
|
||||
if (encryptedText == null) return null;
|
||||
try {
|
||||
SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES");
|
||||
Cipher cipher = Cipher.getInstance("AES");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
|
||||
return new String(decryptedBytes);
|
||||
} catch (Exception e) {
|
||||
// Might not be encrypted or key changed, return original or throw. For safety, return as is if decoding fails (e.g., existing unencrypted data).
|
||||
return encryptedText;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ spring:
|
||||
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
mode: always
|
||||
schema-locations: classpath:schema.sql
|
||||
|
||||
data:
|
||||
|
||||
29
kifi-api/src/main/resources/policies/privacy-policy.html
Normal file
29
kifi-api/src/main/resources/policies/privacy-policy.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Privacy Policy</title>
|
||||
<style>body { font-family: sans-serif; padding: 20px; line-height: 1.6; color: #333; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Last updated: August 25, 2026</p>
|
||||
|
||||
<h2>1. Information Collected</h2>
|
||||
<p>We collect personal information such as your name, username, and email. We may optionally collect your mobile number. For business accounts, we collect business names, addresses, tax identifiers (PAN, GSTIN), and MSME details.</p>
|
||||
|
||||
<h2>2. Purpose of Processing</h2>
|
||||
<p>We use this information to provide the Kifi service, facilitate accounting and invoice generation, and manage your user account.</p>
|
||||
|
||||
<h2>3. Encryption and Security</h2>
|
||||
<p>Sensitive Personally Identifiable Information (PII) such as Mobile Numbers, PAN, GSTIN, and MSME numbers are encrypted at rest in our database. We mask these identifiers in the application interface unless legally required (e.g., on an invoice).</p>
|
||||
|
||||
<h2>4. Data Storage and Sharing</h2>
|
||||
<p>Your data is securely stored. We do not sell your personal information. We may share data with third-party service providers acting on our behalf, or when legally compelled.</p>
|
||||
|
||||
<h2>5. User Rights</h2>
|
||||
<p>You have the right to access, update, or request deletion of your personal data through the application settings or by contacting our support.</p>
|
||||
|
||||
<h2>6. Consent</h2>
|
||||
<p>By completing the account setup, you explicitly consent to the collection and processing of your data as described in this policy.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Terms and Conditions</title>
|
||||
<style>body { font-family: sans-serif; padding: 20px; line-height: 1.6; color: #333; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Terms and Conditions</h1>
|
||||
<p>Last updated: August 25, 2026</p>
|
||||
|
||||
<h2>1. Application Usage</h2>
|
||||
<p>By using the Kifi application, you agree to comply with these terms. Kifi is an enterprise-grade financial and project management tool.</p>
|
||||
|
||||
<h2>2. User Account Responsibilities</h2>
|
||||
<p>You are responsible for maintaining the confidentiality of your account credentials. The accuracy of the information provided during account setup, including business details, is your responsibility.</p>
|
||||
|
||||
<h2>3. Financial Information Disclaimer</h2>
|
||||
<p>Kifi provides personal and business accounting features. We do not provide financial, legal, or tax advice. You should consult a professional for your specific accounting and tax obligations.</p>
|
||||
|
||||
<h2>4. Prohibited Use</h2>
|
||||
<p>You may not use Kifi for illegal activities, nor may you compromise the security or integrity of the platform.</p>
|
||||
|
||||
<h2>5. Limitation of Liability</h2>
|
||||
<p>We provide the service "as is". We are not liable for direct, indirect, incidental, or consequential damages resulting from your use of the service or any data loss.</p>
|
||||
|
||||
<h2>6. Changes and Termination</h2>
|
||||
<p>We reserve the right to suspend or terminate accounts that violate these terms. We may modify these terms at any time; continued use constitutes acceptance.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -398,3 +398,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
|
||||
images TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ACCOUNT SETUP FLOW MIGRATIONS --
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS mobile_number VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_status VARCHAR(50) DEFAULT 'NOT_STARTED';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_completed_at TIMESTAMP;
|
||||
|
||||
ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS nature_of_business VARCHAR(50);
|
||||
ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS msme_number VARCHAR(255);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_consents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
consent_type VARCHAR(100) NOT NULL,
|
||||
policy_version VARCHAR(50) NOT NULL,
|
||||
accepted BOOLEAN DEFAULT TRUE,
|
||||
accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(100),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user