Modifiche

This commit is contained in:
Lorenzo Sanesi
2026-04-01 23:49:19 +02:00
parent 8752acd2c4
commit e2fe8b264f
52 changed files with 1351 additions and 400 deletions
+2
View File
@@ -6,3 +6,5 @@
/frontend-angular/.angular/
/backend-node/node_modules/
/backend-node/.env
/frontend-angular/node_modules/
/.claude/
+2 -2
View File
@@ -22,7 +22,7 @@ COPY package*.json ./
FROM base AS dependencies
# Install ALL dependencies (including devDependencies for build)
RUN npm ci
RUN npm install
# ========================================
# Stage 3: Development
@@ -50,7 +50,7 @@ CMD ["npm", "start"]
FROM base AS production-dependencies
# Install only production dependencies
RUN npm ci --only=production && \
RUN npm install --only=production && \
npm cache clean --force
# ========================================
@@ -5,6 +5,24 @@ class BetsController {
this.betsService = betsService;
}
async getUserBets(req, res, next) {
try {
const bets = await this.betsService.getUserBets(req.userId);
return ResponseHandler.success(res, bets, 'User bets retrieved successfully');
} catch (error) {
next(error);
}
}
async getPublicBets(req, res, next) {
try {
const bets = await this.betsService.getPublicBetsExcludingUser(req.userId);
return ResponseHandler.success(res, bets, 'Public bets retrieved successfully');
} catch (error) {
next(error);
}
}
async getAllBets(req, res, next) {
try {
const bets = await this.betsService.getAllBets();
@@ -7,7 +7,7 @@ class GroupsController {
async getAllGroups(req, res, next) {
try {
const groups = await this.groupsService.getAllGroups();
const groups = await this.groupsService.getAllGroups(req.userId);
return ResponseHandler.success(res, groups, 'Groups retrieved successfully');
} catch (error) {
next(error);
@@ -52,10 +52,7 @@ class GroupsController {
async addMembers(req, res, next) {
try {
const group = await this.groupsService.addMembersToGroup(
req.params.id,
req.body.userIds
);
const group = await this.groupsService.addMembersToGroup(req.params.id, req.body.userIds);
return ResponseHandler.success(res, group, 'Members added successfully');
} catch (error) {
next(error);
@@ -1,38 +0,0 @@
const ResponseHandler = require('../shared/response.handler');
class InitialDataController {
constructor({ usersService, friendsService, groupsService, betsService }) {
this.usersService = usersService;
this.friendsService = friendsService;
this.groupsService = groupsService;
this.betsService = betsService;
}
async getInitialData(req, res, next) {
try {
const currentUser = await this.usersService.getUserById(req.userId);
const users = await this.usersService.getAllUsers();
const friends = await this.friendsService.getFriendsByUserId(req.userId);
const friendRequests = await this.friendsService.getPendingRequests(req.userId);
const groups = await this.groupsService.getAllGroups();
const userBets = await this.betsService.getUserBets(req.userId);
const publicBets = await this.betsService.getPublicBetsExcludingUser(req.userId);
const data = {
currentUser,
users,
friends,
friendRequests,
groups,
userBets,
publicBets,
};
return ResponseHandler.success(res, data, 'Initial data retrieved successfully');
} catch (error) {
next(error);
}
}
}
module.exports = InitialDataController;
@@ -5,6 +5,15 @@ class UsersController {
this.usersService = usersService;
}
async getCurrentUser(req, res, next) {
try {
const user = await this.usersService.getUserById(req.userId);
return ResponseHandler.success(res, user, 'User retrieved successfully');
} catch (error) {
next(error);
}
}
async updateCurrentUser(req, res, next) {
try {
const user = await this.usersService.updateUserProfile(req.userId, req.body);
+6 -17
View File
@@ -18,13 +18,13 @@ function setupContainer() {
const modelsDir = path.join(__dirname, '../models');
fs.readdirSync(modelsDir)
.filter(
(file) =>
file =>
file.indexOf('.') !== 0 &&
file.slice(-3) === '.js' &&
file !== 'bet.model.js' &&
file !== 'associations.js'
)
.forEach((file) => {
.forEach(file => {
require(path.join(modelsDir, file))(sequelize);
});
@@ -38,27 +38,19 @@ function setupContainer() {
});
container.register({
usersRepository: asClass(
require('../repositories/postgres/users.repository')
).scoped(),
groupsRepository: asClass(
require('../repositories/postgres/groups.repository')
).scoped(),
usersRepository: asClass(require('../repositories/postgres/users.repository')).scoped(),
groupsRepository: asClass(require('../repositories/postgres/groups.repository')).scoped(),
friendshipsRepository: asClass(
require('../repositories/postgres/friendships.repository')
).scoped(),
friendRequestsRepository: asClass(
require('../repositories/postgres/friendRequests.repository')
).scoped(),
betsRepository: asClass(
require('../repositories/mongo/bets.repository')
).scoped(),
betsRepository: asClass(require('../repositories/mongo/bets.repository')).scoped(),
});
container.register({
dataStitchingService: asClass(
require('../services/data-stitching.service')
).scoped(),
dataStitchingService: asClass(require('../services/data-stitching.service')).scoped(),
usersService: asClass(require('../services/users.service')).scoped(),
groupsService: asClass(require('../services/groups.service')).scoped(),
friendsService: asClass(require('../services/friends.service')).scoped(),
@@ -70,9 +62,6 @@ function setupContainer() {
groupsController: asClass(require('../controllers/groups.controller')).scoped(),
usersController: asClass(require('../controllers/users.controller')).scoped(),
friendsController: asClass(require('../controllers/friends.controller')).scoped(),
initialDataController: asClass(
require('../controllers/initialData.controller')
).scoped(),
});
return container;
+2
View File
@@ -11,6 +11,7 @@ DROP TABLE IF EXISTS users CASCADE;
-- users table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
"keycloakId" VARCHAR(255) UNIQUE,
name VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL UNIQUE,
"avatarUrl" VARCHAR(255) NOT NULL,
@@ -63,6 +64,7 @@ CREATE TABLE friend_requests (
-- User <-> Group many-to-many join table
CREATE TABLE "UserGroups" (
id SERIAL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"groupId" INTEGER NOT NULL,
CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE,
+67
View File
@@ -0,0 +1,67 @@
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const client = jwksClient({
jwksUri:
process.env.KEYCLOAK_JWKS_URI ||
'http://localhost:8080/realms/scommettiapp/protocol/openid-connect/certs',
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
module.exports = sequelize => {
const { User } = sequelize.models;
return (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'Token mancante o non valido' });
}
const token = authHeader.slice(7);
jwt.verify(token, getKey, { algorithms: ['RS256'] }, async (err, decoded) => {
if (err) {
return res.status(401).json({ success: false, message: 'Token non valido o scaduto' });
}
try {
const keycloakId = decoded.sub;
const name =
decoded.name ||
`${decoded.given_name || ''} ${decoded.family_name || ''}`.trim() ||
decoded.preferred_username ||
'Utente';
const email = decoded.email || `${keycloakId.slice(0, 8)}@scommetti.app`;
const avatarUrl = `https://picsum.photos/seed/${keycloakId.slice(0, 8)}/200/200`;
// Prima cerca per keycloakId (caso normale dopo il primo login)
let user = await User.findOne({ where: { keycloakId } });
if (!user) {
// Migrazione: l'utente potrebbe esistere già per email
// (creato prima che keycloakId fosse aggiunto al modello)
user = await User.findOne({ where: { email } });
if (user) {
await user.update({ keycloakId });
} else {
user = await User.create({ keycloakId, name, email, avatarUrl, score: 0 });
}
}
req.userId = user.id;
next();
} catch (error) {
next(error);
}
});
};
};
+42 -17
View File
@@ -1,21 +1,46 @@
module.exports = (sequelize) => {
const { User, Group, FriendRequest, Friendship } = sequelize.models;
// eslint-disable-next-line import/no-import-module-exports
import { DataTypes } from 'sequelize';
// User <-> Group: Many-to-Many
// A User can be in many Groups, and a Group can have many Users (members).
const UserGroup = sequelize.define('UserGroup', {}, { timestamps: false });
User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId' });
Group.belongsToMany(User, { through: UserGroup, as: 'members', foreignKey: 'groupId' });
module.exports = sequelize => {
const { User, Group, FriendRequest, Friendship } = sequelize.models;
// Friendship is a direct link between two users.
// user1Id will always be less than user2Id to ensure uniqueness.
Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' });
Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' });
// User <-> Group: Many-to-Many
// Il modello dichiara esplicitamente la colonna id per evitare che sync() la cerchi
// sulla tabella UserGroups senza trovarla (causando errori su RETURNING "id").
const UserGroup = sequelize.define(
'UserGroup',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
userId: { type: DataTypes.INTEGER, allowNull: false },
groupId: { type: DataTypes.INTEGER, allowNull: false },
},
{ timestamps: false }
);
User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId', otherKey: 'groupId' });
Group.belongsToMany(User, {
through: UserGroup,
as: 'members',
foreignKey: 'groupId',
otherKey: 'userId',
});
// User <-> FriendRequest: One-to-Many
// A User can send many friend requests and receive many friend requests.
User.hasMany(FriendRequest, { as: 'sentRequests', foreignKey: 'fromUserId', onDelete: 'CASCADE' });
User.hasMany(FriendRequest, { as: 'receivedRequests', foreignKey: 'toUserId', onDelete: 'CASCADE' });
FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' });
FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' });
// Friendship is a direct link between two users.
// user1Id will always be less than user2Id to ensure uniqueness.
Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' });
Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' });
// User <-> FriendRequest: One-to-Many
// A User can send many friend requests and receive many friend requests.
User.hasMany(FriendRequest, {
as: 'sentRequests',
foreignKey: 'fromUserId',
onDelete: 'CASCADE',
});
User.hasMany(FriendRequest, {
as: 'receivedRequests',
foreignKey: 'toUserId',
onDelete: 'CASCADE',
});
FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' });
FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' });
};
+28 -15
View File
@@ -1,21 +1,34 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const FriendRequest = sequelize.define('FriendRequest', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
module.exports = sequelize => {
const FriendRequest = sequelize.define(
'FriendRequest',
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
status: {
type: DataTypes.STRING,
defaultValue: 'PENDING',
allowNull: false,
},
// Foreign keys 'fromUserId' and 'toUserId' are added via associations
},
status: {
type: DataTypes.STRING,
defaultValue: 'PENDING',
allowNull: false
{
// Uses default `createdAt` and `updatedAt` timestamps from Sequelize
tableName: 'friend_requests',
}
// Foreign keys 'fromUserId' and 'toUserId' are added via associations
}, {
// Uses default `createdAt` and `updatedAt` timestamps from Sequelize
tableName: 'friend_requests'
});
);
FriendRequest.prototype.toJSON = function () {
const values = { ...this.get() };
if (values.id !== undefined) values.id = String(values.id);
if (values.fromUserId !== undefined) values.fromUserId = String(values.fromUserId);
if (values.toUserId !== undefined) values.toUserId = String(values.toUserId);
return values;
};
return FriendRequest;
};
+24 -13
View File
@@ -1,19 +1,30 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Group = sequelize.define('Group', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
module.exports = sequelize => {
const Group = sequelize.define(
'Group',
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
},
name: {
type: DataTypes.STRING,
allowNull: false
{
timestamps: false,
tableName: 'groups',
}
}, {
timestamps: false,
tableName: 'groups'
});
);
Group.prototype.toJSON = function () {
const values = { ...this.get() };
if (values.id !== undefined) values.id = String(values.id);
return values;
};
return Group;
};
+46 -30
View File
@@ -1,35 +1,51 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
module.exports = sequelize => {
const User = sequelize.define(
'User',
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
keycloakId: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: false,
},
score: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
},
name: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: false
},
score: {
type: DataTypes.INTEGER,
defaultValue: 0
},
}, {
timestamps: false,
tableName: 'users'
});
{
timestamps: false,
tableName: 'users',
}
);
User.prototype.toJSON = function () {
const values = { ...this.get() };
if (values.id !== undefined) values.id = String(values.id);
return values;
};
return User;
};
+205
View File
@@ -13,6 +13,8 @@
"cors": "^2.8.5",
"express": "^4.19.2",
"joi": "^17.12.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"mongoose": "^8.4.0",
"pg": "^8.11.5",
"pg-hstore": "^2.3.4",
@@ -294,6 +296,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -653,6 +665,12 @@
"node": ">=16.20.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1003,6 +1021,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2640,6 +2667,15 @@
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2687,6 +2723,94 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz",
"integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/jwks-rsa/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kareem": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
@@ -2720,6 +2844,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2742,6 +2871,48 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2749,6 +2920,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -2758,6 +2935,28 @@
"tslib": "^2.0.3"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4763,6 +4962,12 @@
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -30,6 +30,8 @@
"cors": "^2.8.5",
"express": "^4.19.2",
"joi": "^17.12.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"mongoose": "^8.4.0",
"pg": "^8.11.5",
"pg-hstore": "^2.3.4",
@@ -1,5 +1,5 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors');
class FriendRequestsRepository {
constructor({ sequelize }) {
@@ -30,7 +30,7 @@ class FriendRequestsRepository {
try {
return await this.FriendRequest.findAll({
where: {
toUserId: userId,
[Op.or]: [{ toUserId: userId }, { fromUserId: userId }],
status: 'PENDING',
},
include: [
@@ -39,10 +39,7 @@ class FriendRequestsRepository {
],
});
} catch (error) {
throw new DatabaseError(
`Failed to fetch pending requests for user ${userId}`,
error
);
throw new DatabaseError(`Failed to fetch pending requests for user ${userId}`, error);
}
}
@@ -64,11 +61,12 @@ class FriendRequestsRepository {
async create(fromUserId, toUserId) {
try {
return await this.FriendRequest.create({
const created = await this.FriendRequest.create({
fromUserId,
toUserId,
status: 'PENDING',
});
return await this.findById(created.id);
} catch (error) {
throw new DatabaseError('Failed to create friend request', error);
}
@@ -1,3 +1,4 @@
const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors');
class GroupsRepository {
@@ -15,6 +16,29 @@ class GroupsRepository {
}
}
async findByUserId(userId) {
try {
return await this.Group.findAll({
include: [
{
model: this.User,
as: 'members',
through: { attributes: [] },
},
],
where: {
id: {
[Op.in]: this.sequelize.literal(
`(SELECT "groupId" FROM "UserGroups" WHERE "userId" = ${parseInt(userId, 10)})`
),
},
},
});
} catch (error) {
throw new DatabaseError('Failed to fetch groups for user', error);
}
}
async findById(id, includeMembers = false) {
try {
const options = {};
+22 -23
View File
@@ -1,31 +1,30 @@
const express = require('express');
// Il router è ora una factory function che riceve l'istanza di sequelize
module.exports = (sequelize) => {
const router = express.Router();
module.exports = sequelize => {
const router = express.Router();
// --- Middleware per simulare un utente loggato ---
const { User } = sequelize.models;
const MOCK_USER_NAME = 'Mario Rossi';
let MOCK_USER_ID = null;
// --- Middleware per simulare un utente loggato ---
const { User } = sequelize.models;
const MOCK_USER_NAME = 'Mario Rossi';
let MOCK_USER_ID = null;
router.use(async (req, res, next) => {
if (!MOCK_USER_ID) {
const user = await User.findOne({ where: { name: MOCK_USER_NAME } });
if (user) {
MOCK_USER_ID = user.id;
}
}
req.userId = MOCK_USER_ID;
next();
});
router.use(async (req, res, next) => {
if (!MOCK_USER_ID) {
const user = await User.findOne({ where: { name: MOCK_USER_NAME } });
if (user) {
MOCK_USER_ID = user.id;
}
}
req.userId = MOCK_USER_ID;
next();
});
// --- Monta i router da file separati ---
router.use(require('./initial-data.routes')(sequelize));
router.use('/bets', require('./bets.routes')(sequelize));
router.use('/groups', require('./groups.routes')(sequelize));
router.use('/friend-requests', require('./friends.routes')(sequelize));
router.use('/users', require('./users.routes')(sequelize));
// --- Monta i router da file separati ---
router.use('/bets', require('./bets.routes')(sequelize));
router.use('/groups', require('./groups.routes')(sequelize));
router.use('/friend-requests', require('./friends.routes')(sequelize));
router.use('/users', require('./users.routes')(sequelize));
return router;
return router;
};
@@ -1,59 +0,0 @@
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
// Modello Mongoose per le Scommesse
const Bet = require('../models/bet.model');
module.exports = (sequelize) => {
const { User, Friendship, FriendRequest } = sequelize.models;
const { populateBetsWithUsers } = require('../utils/populateBets')(sequelize);
// GET /api/initial-data
router.get('/initial-data', async (req, res) => {
try {
// --- Query PostgreSQL ---
const currentUser = await User.findByPk(req.userId);
const users = await User.findAll();
const friendships = await Friendship.findAll({
where: { [Op.or]: [{ user1Id: req.userId }, { user2Id: req.userId }] },
include: [{ model: User, as: 'user1' }, { model: User, as: 'user2' }]
});
const friends = friendships.map(f => (f.user1Id === req.userId ? f.user2 : f.user1));
const receivedFriendRequests = await FriendRequest.findAll({
where: { toUserId: req.userId, status: 'PENDING' },
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
const sentFriendRequests = await FriendRequest.findAll({
where: { fromUserId: req.userId, status: 'PENDING' },
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
const userGroups = await currentUser.getGroups({
include: [{ model: User, as: 'members', through: { attributes: [] } }]
});
// --- Query MongoDB ---
const mongoBets = await Bet.find({ 'participants.user': req.userId }).sort({ startDate: -1 });
const mongoPublicBets = await Bet.find({ isPublic: true, 'participants.user': { $ne: req.userId } });
// --- Data Stitching ---
const bets = await populateBetsWithUsers(mongoBets);
const publicBets = await populateBetsWithUsers(mongoPublicBets);
res.json({
currentUser, users, friends, receivedFriendRequests, sentFriendRequests,
bets, publicBets, groups: userGroups
});
} catch (error) {
console.error('Error fetching initial data:', error);
res.status(500).json({ message: 'Error fetching initial data', error: error.message });
}
});
return router;
};
+5 -4
View File
@@ -1,14 +1,13 @@
const express = require('express');
const { validate } = require('../../middleware/validation');
const {
createBetSchema,
updateBetSchema,
} = require('../../shared/validators/schemas/bet.schema');
const { createBetSchema, updateBetSchema } = require('../../shared/validators/schemas/bet.schema');
module.exports = ({ betsController }) => {
const router = express.Router();
const controller = {
getUserBets: (req, res, next) => betsController.getUserBets(req, res, next),
getPublicBets: (req, res, next) => betsController.getPublicBets(req, res, next),
getAllBets: (req, res, next) => betsController.getAllBets(req, res, next),
getBetById: (req, res, next) => betsController.getBetById(req, res, next),
createBet: (req, res, next) => betsController.createBet(req, res, next),
@@ -16,6 +15,8 @@ module.exports = ({ betsController }) => {
deleteBet: (req, res, next) => betsController.deleteBet(req, res, next),
};
router.get('/user', controller.getUserBets);
router.get('/public', controller.getPublicBets);
router.get('/', controller.getAllBets);
router.get('/:id', controller.getBetById);
router.post('/', validate(createBetSchema), controller.createBet);
+1 -3
View File
@@ -1,6 +1,6 @@
const express = require('express');
module.exports = (container) => {
module.exports = container => {
const router = express.Router();
const controllers = {
@@ -8,14 +8,12 @@ module.exports = (container) => {
betsController: container.resolve('betsController'),
usersController: container.resolve('usersController'),
friendsController: container.resolve('friendsController'),
initialDataController: container.resolve('initialDataController'),
};
router.use('/groups', require('./groups.routes')(controllers));
router.use('/bets', require('./bets.routes')(controllers));
router.use('/users', require('./users.routes')(controllers));
router.use('/friend-requests', require('./friends.routes')(controllers));
router.use('/initial-data', require('./initialData.routes')(controllers));
return router;
};
@@ -1,13 +0,0 @@
const express = require('express');
module.exports = ({ initialDataController }) => {
const router = express.Router();
const controller = {
getInitialData: (req, res, next) => initialDataController.getInitialData(req, res, next),
};
router.get('/', controller.getInitialData);
return router;
};
+2
View File
@@ -6,9 +6,11 @@ module.exports = ({ usersController }) => {
const router = express.Router();
const controller = {
getCurrentUser: (req, res, next) => usersController.getCurrentUser(req, res, next),
updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next),
};
router.get('/current', controller.getCurrentUser);
router.put('/current', validate(updateUserSchema), controller.updateCurrentUser);
return router;
+7 -7
View File
@@ -4,8 +4,7 @@ const cors = require('cors');
const config = require('./config');
const setupContainer = require('./di/container');
const errorHandler = require('./middleware/errorHandler');
const mockAuthMiddleware = require('./middleware/auth.mock');
const { seedDatabase } = require('./seed');
const keycloakAuthMiddleware = require('./middleware/auth.keycloak');
const legacyApiRoutes = require('./routes/api');
@@ -21,20 +20,22 @@ const sequelize = container.resolve('sequelize');
mongoose
.connect(config.database.mongo.uri, config.database.mongo.options)
.then(() => console.log('Connected to MongoDB'))
.catch((err) => {
.catch(err => {
console.error('MongoDB connection error:', err);
process.exit(1);
});
app.use('/api', mockAuthMiddleware(sequelize));
app.use('/api', keycloakAuthMiddleware(sequelize));
app.use('/api/legacy', legacyApiRoutes(sequelize));
const v1Routes = require('./routes/v1');
app.use('/api/v1', v1Routes(container));
app.use('/api', v1Routes(container));
const healthRoutes = require('./routes/health.routes');
app.use('/', healthRoutes({ sequelize, mongoose }));
app.use(errorHandler);
@@ -43,11 +44,10 @@ sequelize
.authenticate()
.then(() => {
console.log('Connected to PostgreSQL');
return sequelize.sync({ force: true });
return sequelize.sync({ alter: true });
})
.then(() => {
console.log('PostgreSQL models synchronized');
return seedDatabase(sequelize);
})
.then(() => {
app.listen(config.port, () => {
@@ -58,7 +58,7 @@ sequelize
console.log(`Health check available at /health`);
});
})
.catch((err) => {
.catch(err => {
console.error('PostgreSQL connection or sync error:', err);
process.exit(1);
});
+8 -19
View File
@@ -1,13 +1,8 @@
const { ValidationError, ConflictError } = require('../shared/errors');
const { Op } = require('sequelize');
const { ValidationError, ConflictError } = require('../shared/errors');
class FriendsService {
constructor({
friendshipsRepository,
friendRequestsRepository,
usersRepository,
sequelize,
}) {
constructor({ friendshipsRepository, friendRequestsRepository, usersRepository, sequelize }) {
this.friendshipsRepository = friendshipsRepository;
this.friendRequestsRepository = friendRequestsRepository;
this.usersRepository = usersRepository;
@@ -33,9 +28,7 @@ class FriendsService {
});
if (!toUser) {
throw new ValidationError(
'User not found with provided name or email'
);
throw new ValidationError('User not found with provided name or email');
}
}
@@ -43,18 +36,12 @@ class FriendsService {
throw new ValidationError('Cannot send friend request to yourself');
}
const friendshipExists = await this.friendshipsRepository.exists(
fromUserId,
toUser.id
);
const friendshipExists = await this.friendshipsRepository.exists(fromUserId, toUser.id);
if (friendshipExists) {
throw new ConflictError('You are already friends with this user');
}
const existingRequest = await this.friendRequestsRepository.findExisting(
fromUserId,
toUser.id
);
const existingRequest = await this.friendRequestsRepository.findExisting(fromUserId, toUser.id);
if (existingRequest) {
throw new ConflictError('Friend request already exists');
}
@@ -73,6 +60,8 @@ class FriendsService {
throw new ValidationError('This request has already been processed');
}
const newFriend = request.fromUser;
const t = await this.sequelize.transaction();
try {
await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED');
@@ -80,7 +69,7 @@ class FriendsService {
await this.friendRequestsRepository.delete(requestId);
await t.commit();
return { message: 'Friend request accepted successfully' };
return { message: 'Friend request accepted successfully', newFriend };
} catch (error) {
await t.rollback();
throw error;
+2 -10
View File
@@ -6,16 +6,8 @@ class GroupsService {
this.usersRepository = usersRepository;
}
async getAllGroups() {
return await this.groupsRepository.findAll({
include: [
{
model: this.usersRepository.User,
as: 'members',
through: { attributes: [] },
},
],
});
async getAllGroups(userId) {
return await this.groupsRepository.findByUserId(userId);
}
async getGroupById(groupId) {
+57 -1
View File
@@ -42,6 +42,57 @@ services:
timeout: 5s
retries: 5
# Keycloak Database
keycloak-db:
image: postgres:15-alpine
container_name: scommetti-keycloak-db
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
volumes:
- keycloak_db_data:/var/lib/postgresql/data
networks:
- scommetti-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 10s
timeout: 5s
retries: 5
# Keycloak Auth Server
keycloak:
image: quay.io/keycloak/keycloak:23.0.0
container_name: scommetti-keycloak
command: start-dev --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "8080:8080"
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro
- ./keycloak/themes/scommettiapp:/opt/keycloak/themes/scommettiapp:ro
depends_on:
keycloak-db:
condition: service_healthy
networks:
- scommetti-network
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && timeout 5 cat <&3 | grep -q 'UP'"]
interval: 15s
timeout: 15s
retries: 10
start_period: 90s
# Backend Node.js
backend:
build:
@@ -53,6 +104,7 @@ services:
PORT: 3000
POSTGRES_URI: postgres://admin:admin@postgres:5432/amici_scommesse
MONGO_URI: mongodb://mongodb:27017/amici-scommesse
KEYCLOAK_JWKS_URI: http://keycloak:8080/realms/scommettiapp/protocol/openid-connect/certs
ports:
- "3000:3000"
volumes:
@@ -63,6 +115,8 @@ services:
condition: service_healthy
mongodb:
condition: service_healthy
keycloak:
condition: service_healthy
networks:
- scommetti-network
command: npm start
@@ -84,9 +138,9 @@ services:
- /app/.angular
depends_on:
- backend
- keycloak
networks:
- scommetti-network
command: npm run dev
restart: unless-stopped
volumes:
@@ -94,6 +148,8 @@ volumes:
driver: local
mongodb_data:
driver: local
keycloak_db_data:
driver: local
networks:
scommetti-network:
+1 -1
View File
@@ -8,7 +8,7 @@ WORKDIR /app
COPY package*.json ./
# Install dependencies
RUN npm ci
RUN npm install
# Copy application code
COPY . .
+1 -1
View File
@@ -34,7 +34,7 @@
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 3000
"port": 4200
},
"configurations": {
"production": {
+29 -8
View File
@@ -1,15 +1,36 @@
import '@angular/compiler';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import Keycloak from 'keycloak-js';
import { AppComponent } from './src/app.component';
import { KEYCLOAK_INSTANCE } from './src/core/tokens/keycloak.token';
import { authInterceptor } from './src/core/interceptors/auth.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch()),
],
}).catch(err => console.error(err));
const keycloak = new Keycloak({
url: 'http://localhost:8080',
realm: 'scommettiapp',
clientId: 'scommetti-frontend',
});
// AI Studio always uses an `index.tsx` file for all project types.
keycloak.init({
onLoad: 'login-required',
checkLoginIframe: false,
pkceMethod: 'S256',
}).then(authenticated => {
if (!authenticated) {
keycloak.login();
return;
}
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
{ provide: KEYCLOAK_INSTANCE, useValue: keycloak },
],
}).catch(err => console.error(err));
}).catch(err => {
console.error('Keycloak initialization failed:', err);
});
+26
View File
@@ -15,6 +15,7 @@
"@angular/compiler-cli": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"keycloak-js": "^24.0.0",
"rxjs": "^7.8.2",
"tailwindcss": "latest"
},
@@ -4931,6 +4932,12 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-sha256": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4991,6 +4998,25 @@
],
"license": "MIT"
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keycloak-js": {
"version": "24.0.5",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-24.0.5.tgz",
"integrity": "sha512-VQOSn3j13DPB6OuavKAq+sRjDERhIKrXgBzekoHRstifPuyULILguugX6yxRUYFSpn3OMYUXmSX++tkdCupOjA==",
"license": "Apache-2.0",
"dependencies": {
"js-sha256": "^0.11.0",
"jwt-decode": "^4.0.0"
}
},
"node_modules/listr2": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@angular/build": "^20.3.0",
"@angular/cli": "^20.3.0",
"@angular/compiler-cli": "^20.3.0",
"keycloak-js": "^24.0.0",
"tailwindcss": "latest"
},
"devDependencies": {
+8 -1
View File
@@ -1,10 +1,11 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './features/home/home.component';
import { DiscoverComponent } from './features/discover/discover.component';
import { GroupsComponent } from './features/groups/groups.component';
import { ProfileComponent } from './features/profile/profile.component';
import { CreateBetComponent } from './features/bets/create-bet.component';
import { DataService } from './core/services/data.service';
type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet';
@@ -23,9 +24,15 @@ type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
private dataService = inject(DataService);
currentView = signal<View>('home');
private previousView = signal<View>('home');
constructor() {
this.dataService.loadCurrentUser();
}
setView(view: View) {
if (view === 'create-bet') {
this.previousView.set(this.currentView());
@@ -0,0 +1,18 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { from, switchMap } from 'rxjs';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
return from(authService.getValidToken()).pipe(
switchMap(token => {
if (!token) return next(req);
const authReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next(authReq);
})
);
};
@@ -1,8 +1,3 @@
import { User } from './user.model';
import { Group } from './group.model';
import { Bet } from './bet.model';
import { FriendRequest } from './friend-request.model';
export interface SuccessResponse<T = any> {
success: boolean;
message?: string;
@@ -21,16 +16,3 @@ export interface ErrorResponse {
errors?: ErrorDetail[];
timestamp: string;
}
export interface InitialData {
currentUser: User;
users: User[];
friends: User[];
friendRequests: FriendRequest[];
receivedFriendRequests?: FriendRequest[]; // Legacy or convenience
sentFriendRequests?: FriendRequest[]; // Legacy or convenience
groups: Group[];
userBets: Bet[];
bets?: Bet[]; // Compatibility with data.service
publicBets: Bet[];
}
@@ -21,6 +21,18 @@ export class BetApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/bets';
getUserBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(`${this.apiUrl}/user`).pipe(
map(res => res.data.map(mapBet))
);
}
getPublicBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(`${this.apiUrl}/public`).pipe(
map(res => res.data.map(mapBet))
);
}
getBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(this.apiUrl).pipe(
map(res => res.data.map(mapBet))
@@ -13,6 +13,18 @@ export class FriendRequestApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/friend-requests';
getFriends(): Observable<User[]> {
return this.http.get<SuccessResponse<User[]>>(this.apiUrl).pipe(
map(res => res.data)
);
}
getPendingRequests(): Observable<FriendRequest[]> {
return this.http.get<SuccessResponse<FriendRequest[]>>(`${this.apiUrl}/pending`).pipe(
map(res => res.data)
);
}
sendRequest(request: SendFriendRequestRequest): Observable<FriendRequest> {
return this.http.post<SuccessResponse<FriendRequest>>(this.apiUrl, request).pipe(
map(res => res.data)
@@ -2,24 +2,8 @@ import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { User, UpdateUserRequest } from '../../models/user.model';
import { SuccessResponse, InitialData } from '../../models/api-responses.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
import { toDate } from '../../utils/type-guards';
function mapInitialData(data: InitialData): InitialData {
const mapBet = (bet: any) => ({
...bet,
startDate: toDate(bet.startDate)!,
endDate: toDate(bet.endDate)
});
return {
...data,
userBets: (data.userBets || []).map(mapBet),
publicBets: (data.publicBets || []).map(mapBet),
bets: (data.bets || []).map(mapBet)
};
}
@Injectable({
providedIn: 'root'
@@ -28,9 +12,9 @@ export class UserApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1';
getInitialData(): Observable<InitialData> {
return this.http.get<SuccessResponse<InitialData>>(`${this.apiUrl}/initial-data`).pipe(
map(res => mapInitialData(res.data))
getCurrentUser(): Observable<User> {
return this.http.get<SuccessResponse<User>>(`${this.apiUrl}/users/current`).pipe(
map(res => res.data)
);
}
@@ -39,6 +23,4 @@ export class UserApiService {
map(res => res.data)
);
}
// Potential future: getProfile(id), etc.
}
@@ -0,0 +1,27 @@
import { Injectable, inject } from '@angular/core';
import { KEYCLOAK_INSTANCE } from '../tokens/keycloak.token';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private keycloak = inject(KEYCLOAK_INSTANCE);
async getValidToken(): Promise<string> {
try {
await this.keycloak.updateToken(30);
} catch {
this.keycloak.login();
return '';
}
return this.keycloak.token!;
}
get tokenParsed() {
return this.keycloak.tokenParsed;
}
logout() {
this.keycloak.logout({ redirectUri: window.location.origin });
}
}
@@ -18,7 +18,6 @@ export class DataService {
private userApiService = inject(UserApiService);
private friendRequestApiService = inject(FriendRequestApiService);
private readonly _users = signal<User[]>([]);
private readonly _bets = signal<Bet[]>([]);
private readonly _groups = signal<Group[]>([]);
private readonly _friends = signal<User[]>([]);
@@ -47,26 +46,64 @@ export class DataService {
readonly getPublicBets = computed(() => this._publicBets());
constructor() {
this._initializeData();
// --- Load methods: chiamati da ogni componente al momento della visualizzazione ---
async loadCurrentUser(): Promise<void> {
try {
const user = await firstValueFrom(this.userApiService.getCurrentUser());
this.currentUser.set(user);
} catch (error) {
console.error('Failed to load current user', error);
}
}
private _initializeData() {
this.userApiService.getInitialData().subscribe({
next: data => {
this.currentUser.set(data.currentUser);
this._users.set(data.users);
this._friends.set(data.friends);
// Combine received and sent requests if they come separated, or use the array if unified
this._friendRequests.set(data.friendRequests || []);
this._bets.set(data.userBets || data.bets || []);
this._publicBets.set(data.publicBets);
this._groups.set(data.groups);
},
error: err => console.error("Failed to load initial data", err)
});
async loadUserBets(): Promise<void> {
try {
const bets = await firstValueFrom(this.betApiService.getUserBets());
this._bets.set(bets);
} catch (error) {
console.error('Failed to load user bets', error);
}
}
async loadPublicBets(): Promise<void> {
try {
const bets = await firstValueFrom(this.betApiService.getPublicBets());
this._publicBets.set(bets);
} catch (error) {
console.error('Failed to load public bets', error);
}
}
async loadFriends(): Promise<void> {
try {
const friends = await firstValueFrom(this.friendRequestApiService.getFriends());
this._friends.set(friends);
} catch (error) {
console.error('Failed to load friends', error);
}
}
async loadFriendRequests(): Promise<void> {
try {
const requests = await firstValueFrom(this.friendRequestApiService.getPendingRequests());
this._friendRequests.set(requests);
} catch (error) {
console.error('Failed to load friend requests', error);
}
}
async loadGroups(): Promise<void> {
try {
const groups = await firstValueFrom(this.groupApiService.getGroups());
this._groups.set(groups);
} catch (error) {
console.error('Failed to load groups', error);
}
}
// --- Utility ---
getGroupById(id: string): Group | undefined {
return this._groups().find(g => g.id === id);
}
@@ -75,6 +112,8 @@ export class DataService {
return this._bets().filter(b => b.groupId === groupId);
}
// --- Mutations ---
async sendFriendRequest(identifier: string): Promise<{ success: boolean, message: string }> {
try {
const newRequest = await firstValueFrom(this.friendRequestApiService.sendRequest({ identifier }));
@@ -90,7 +129,7 @@ export class DataService {
await firstValueFrom(this.friendRequestApiService.cancelRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) {
console.error("Failed to cancel friend request", error);
console.error('Failed to cancel friend request', error);
}
}
@@ -100,7 +139,7 @@ export class DataService {
this._friends.update(f => [...f, newFriend]);
this._friendRequests.update(reqs => reqs.filter(r => r.id !== request.id));
} catch (error) {
console.error("Failed to accept friend request", error);
console.error('Failed to accept friend request', error);
}
}
@@ -109,7 +148,7 @@ export class DataService {
await firstValueFrom(this.friendRequestApiService.declineRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) {
console.error("Failed to decline friend request", error);
console.error('Failed to decline friend request', error);
}
}
@@ -119,7 +158,7 @@ export class DataService {
const newGroup = await firstValueFrom(this.groupApiService.createGroup({ name, members: memberIds }));
this._groups.update(groups => [...groups, newGroup]);
} catch (error) {
console.error("Failed to add group", error);
console.error('Failed to add group', error);
}
}
@@ -128,7 +167,7 @@ export class DataService {
const updatedGroup = await firstValueFrom(this.groupApiService.removeMember(groupId, userId));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) {
console.error("Failed to remove user from group", error);
console.error('Failed to remove user from group', error);
}
}
@@ -138,7 +177,7 @@ export class DataService {
const updatedGroup = await firstValueFrom(this.groupApiService.addMembers(groupId, { userIds }));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) {
console.error("Failed to add users to group", error);
console.error('Failed to add users to group', error);
}
}
@@ -146,9 +185,8 @@ export class DataService {
try {
const updatedUser = await firstValueFrom(this.userApiService.updateCurrentUser(data));
this.currentUser.set(updatedUser);
this._users.update(users => users.map(u => u.id === updatedUser.id ? updatedUser : u));
} catch (error) {
console.error("Failed to update user", error);
console.error('Failed to update user', error);
}
}
@@ -180,7 +218,7 @@ export class DataService {
}));
this._bets.update(bets => [newBet, ...bets]);
} catch (error) {
console.error("Failed to add bet", error);
console.error('Failed to add bet', error);
}
}
}
@@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import Keycloak from 'keycloak-js';
export const KEYCLOAK_INSTANCE = new InjectionToken<Keycloak>('KeycloakInstance');
@@ -2,7 +2,6 @@ import { Component, ChangeDetectionStrategy, output, inject, signal, computed, O
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetParticipant, BetRole } from '../../core/models/bet.model';
import { Group } from '../../core/models/group.model';
import { User } from '../../core/models/user.model';
import { getUserId } from '../../core/utils/type-guards';
@@ -51,6 +50,9 @@ export class CreateBetComponent implements OnInit {
});
ngOnInit() {
this.dataService.loadFriends();
this.dataService.loadGroups();
const user = this.currentUser();
if (user) {
this.participants.set([{ user, role: BetRole.Participant }]);
@@ -59,13 +61,13 @@ export class CreateBetComponent implements OnInit {
addParticipant(friend: User) {
this.participants.update(p => [...p, { user: friend, role: BetRole.Participant }]);
this.groupId.set(undefined); // Reset group if participants are manually changed
this.groupId.set(undefined);
this.showFriendSelector.set(false);
}
removeParticipant(userId: string) {
this.participants.update(p => p.filter(participant => getUserId(participant.user) !== userId));
this.groupId.set(undefined); // Reset group if participants are manually changed
this.groupId.set(undefined);
}
updateParticipantRole(userId: string, newRole: BetRole) {
@@ -94,7 +96,6 @@ export class CreateBetComponent implements OnInit {
this.participants.set(groupParticipants);
}
} else {
// Reset to just the current user if "Nessun gruppo" is selected
const user = this.currentUser();
if (user) {
this.participants.set([{ user, role: BetRole.Participant }]);
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetCardComponent } from '../../shared/components/bet-card/bet-card.component';
@@ -10,7 +10,11 @@ import { BetCardComponent } from '../../shared/components/bet-card/bet-card.comp
templateUrl: './discover.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiscoverComponent {
export class DiscoverComponent implements OnInit {
private dataService = inject(DataService);
publicBets = this.dataService.getPublicBets;
readonly publicBets = this.dataService.getPublicBets;
ngOnInit() {
this.dataService.loadPublicBets();
}
}
@@ -1,10 +1,8 @@
import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed, effect } from '@angular/core';
import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service';
import { Bet, BetStatus } from '../../../core/models/bet.model';
import { Group } from '../../../core/models/group.model';
import { User } from '../../../core/models/user.model';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
import { DataService } from '@/src/core/services/data.service';
import { Bet, BetStatus } from '@/src/core/models/bet.model';
import { BetCardComponent } from '@/src/shared/components/bet-card/bet-card.component';
@Component({
selector: 'app-group-detail',
@@ -22,30 +20,21 @@ export class GroupDetailComponent {
private allGroups = this.dataService.userGroups;
group = computed(() => this.allGroups().find(g => g.id === this.groupId()) ?? null);
groupBets = signal<Bet[]>([]);
readonly groupBets = computed(() => this.dataService.getBetsByGroupId(this.groupId()));
isAddingMembers = signal(false);
selectedFriendIds = signal<{ [key: string]: boolean }>({});
constructor() {
effect(() => {
const id = this.groupId();
if (id) {
this.groupBets.set(this.dataService.getBetsByGroupId(id));
}
});
}
sortedMembers = computed(() => {
return this.group()?.members.slice().sort((a, b) => b.score - a.score) ?? [];
});
activeBets = computed(() => {
return this.groupBets().filter(b => b.status === BetStatus.Active || b.status === BetStatus.AwaitingValidation);
return this.groupBets().filter((b: Bet) => b.status === BetStatus.Active || b.status === BetStatus.AwaitingValidation);
});
pastBets = computed(() => {
return this.groupBets().filter(b => b.status === BetStatus.Completed || b.status === BetStatus.Expired);
return this.groupBets().filter((b: Bet) => b.status === BetStatus.Completed || b.status === BetStatus.Expired);
});
availableFriends = computed(() => {
@@ -1,7 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { User } from '../../core/models/user.model';
import { GroupDetailComponent } from './group-detail/group-detail.component';
@Component({
@@ -11,7 +10,7 @@ import { GroupDetailComponent } from './group-detail/group-detail.component';
templateUrl: './groups.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GroupsComponent {
export class GroupsComponent implements OnInit {
private dataService = inject(DataService);
groups = this.dataService.userGroups;
@@ -27,6 +26,12 @@ export class GroupsComponent {
return this.newGroupName().trim().length > 0 && Object.keys(this.selectedFriendIds()).length > 0;
});
ngOnInit() {
this.dataService.loadGroups();
this.dataService.loadFriends();
this.dataService.loadUserBets();
}
selectGroup(groupId: string) {
this.selectedGroupId.set(groupId);
}
@@ -1,7 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { Bet } from '../../core/models/bet.model';
import { BetsByTimeComponent } from './bets-by-time/bets-by-time.component';
import { BetsByGroupComponent } from './bets-by-group/bets-by-group.component';
@@ -16,12 +15,10 @@ export class HomeComponent implements OnInit {
private dataService = inject(DataService);
viewMode = signal<'time' | 'group'>('time');
allBets = signal<Bet[]>([]);
readonly allBets = this.dataService.getActiveBets;
ngOnInit() {
// FIX: getActiveBets is a computed signal which returns a value when called once.
// The extra parentheses caused an error because it tried to execute an array.
this.allBets.set(this.dataService.getActiveBets());
this.dataService.loadUserBets();
}
setViewMode(mode: 'time' | 'group') {
@@ -3,7 +3,7 @@
<!-- Header with Logout -->
<header class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold">Profilo</h1>
<button title="Logout" class="p-2 text-slate-400 hover:text-white rounded-full hover:bg-slate-700 transition-colors">
<button (click)="logout()" title="Logout" class="p-2 text-slate-400 hover:text-white rounded-full hover:bg-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { AuthService } from '../../core/services/auth.service';
import { EditProfileComponent } from './edit-profile/edit-profile.component';
import { FriendRequest } from '../../core/models/friend-request.model';
@@ -11,8 +12,9 @@ import { FriendRequest } from '../../core/models/friend-request.model';
templateUrl: './profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileComponent {
export class ProfileComponent implements OnInit {
private dataService = inject(DataService);
private authService = inject(AuthService);
currentUser = this.dataService.currentUser;
friends = this.dataService.friends;
@@ -26,6 +28,12 @@ export class ProfileComponent {
addFriendError = signal<string | null>(null);
addFriendSuccess = signal<string | null>(null);
ngOnInit() {
this.dataService.loadCurrentUser();
this.dataService.loadFriends();
this.dataService.loadFriendRequests();
}
toggleEditMode() {
this.isEditing.update(val => !val);
}
@@ -74,4 +82,8 @@ export class ProfileComponent {
declineRequest(requestId: string) {
this.dataService.declineFriendRequest(requestId);
}
logout() {
this.authService.logout();
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"id": "scommettiapp",
"realm": "scommettiapp",
"enabled": true,
"loginTheme": "scommettiapp",
"registrationAllowed": true,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"clients": [
{
"clientId": "scommetti-frontend",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://localhost:4200/*"
],
"webOrigins": [
"http://localhost:4200"
],
"attributes": {
"pkce.code.challenge.method": "S256"
}
},
{
"clientId": "scommetti-backend",
"enabled": true,
"publicClient": false,
"secret": "your-backend-client-secret-123",
"protocol": "openid-connect",
"standardFlowEnabled": false,
"serviceAccountsEnabled": true
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Base user role"
},
{
"name": "admin",
"description": "Administrator role"
}
]
},
"defaultRoles": ["user"]
}
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScommettiApp - Accedi</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f1f5f9;
-webkit-box-shadow: 0 0 0px 1000px #0f172a inset;
transition: background-color 5000s ease-in-out 0s;
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md fade-in">
<!-- Logo / Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-cyan-500 rounded-2xl mb-4 shadow-lg shadow-cyan-500/20">
<svg class="w-9 h-9 text-[#0f172a]" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="2"/>
<rect x="13" y="2" width="9" height="9" rx="2"/>
<rect x="2" y="13" width="9" height="9" rx="2"/>
<rect x="13" y="13" width="9" height="9" rx="2"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="#06b6d4"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight">ScommettiApp</h1>
<p class="text-[#94a3b8] text-sm mt-1">Sfida i tuoi amici</p>
</div>
<!-- Card -->
<div class="bg-[#1e293b] border border-[#334155] rounded-2xl p-8 shadow-xl">
<h2 class="text-lg font-semibold text-white mb-6">Accedi al tuo account</h2>
<!-- Messaggio globale (errore / info) -->
<#if message?has_content>
<div class="rounded-xl p-4 mb-6 text-sm
<#if message.type == 'error'>bg-red-500/10 border border-red-500/30 text-red-400
<#elseif message.type == 'success'>bg-green-500/10 border border-green-500/30 text-green-400
<#elseif message.type == 'warning'>bg-yellow-500/10 border border-yellow-500/30 text-yellow-400
<#else>bg-blue-500/10 border border-blue-500/30 text-blue-400</#if>">
${message.summary?no_esc}
</div>
</#if>
<form action="${url.loginAction}" method="post" novalidate>
<!-- Campo username / email -->
<div class="mb-5">
<label for="username" class="block text-[#cbd5e1] text-sm font-medium mb-2">
<#if realm.loginWithEmailAllowed && !realm.registrationEmailAsUsername>
Email o Username
<#elseif realm.loginWithEmailAllowed>
Email
<#else>
Username
</#if>
</label>
<input
type="text"
id="username"
name="username"
value="${(login.username!'')}"
class="w-full bg-[#0f172a] border border-[#334155] rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm"
placeholder="<#if realm.loginWithEmailAllowed>nome@esempio.it<#else>il tuo username</#if>"
autocomplete="username"
autofocus
/>
</div>
<!-- Campo password -->
<div class="mb-5">
<label for="password" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Password
</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
class="w-full bg-[#0f172a] border border-[#334155] rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm"
placeholder="La tua password"
autocomplete="current-password"
/>
<button
type="button"
onclick="togglePassword('password', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<svg id="eye-icon-password" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
</div>
<!-- Ricordami + Password dimenticata -->
<div class="flex items-center justify-between mb-6">
<#if realm.rememberMe>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
id="rememberMe"
name="rememberMe"
<#if login.rememberMe??>checked</#if>
class="w-4 h-4 rounded border-[#334155] bg-[#0f172a] accent-cyan-500 cursor-pointer"
/>
<span class="text-[#94a3b8] text-sm">Ricordami</span>
</label>
<#else>
<span></span>
</#if>
<#if realm.resetPasswordAllowed>
<a href="${url.loginResetCredentialsUrl}"
class="text-cyan-400 text-sm hover:text-cyan-300 transition-colors">
Password dimenticata?
</a>
</#if>
</div>
<!-- Bottone accedi -->
<button
type="submit"
class="w-full bg-cyan-500 hover:bg-cyan-400 active:bg-cyan-600 text-[#0f172a] font-semibold py-3 rounded-xl transition-colors text-sm"
>
Accedi
</button>
</form>
<!-- Link registrazione -->
<#if realm.registrationAllowed>
<p class="text-center text-[#94a3b8] mt-6 text-sm">
Non hai un account?
<a href="${url.registrationUrl}"
class="text-cyan-400 hover:text-cyan-300 transition-colors font-medium ml-1">
Registrati
</a>
</p>
</#if>
</div>
</div>
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const svg = btn.querySelector('svg');
if (isHidden) {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7
a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243
M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29
M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7
a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>`;
} else {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>`;
}
}
</script>
</body>
</html>
@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScommettiApp - Registrati</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f1f5f9;
-webkit-box-shadow: 0 0 0px 1000px #0f172a inset;
transition: background-color 5000s ease-in-out 0s;
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.field-error {
border-color: #f87171 !important;
}
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-4 py-10">
<div class="w-full max-w-md fade-in">
<!-- Logo / Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-cyan-500 rounded-2xl mb-4 shadow-lg shadow-cyan-500/20">
<svg class="w-9 h-9 text-[#0f172a]" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="2"/>
<rect x="13" y="2" width="9" height="9" rx="2"/>
<rect x="2" y="13" width="9" height="9" rx="2"/>
<rect x="13" y="13" width="9" height="9" rx="2"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="#06b6d4"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight">ScommettiApp</h1>
<p class="text-[#94a3b8] text-sm mt-1">Crea il tuo account</p>
</div>
<!-- Card -->
<div class="bg-[#1e293b] border border-[#334155] rounded-2xl p-8 shadow-xl">
<h2 class="text-lg font-semibold text-white mb-6">Registrazione</h2>
<!-- Messaggio globale -->
<#if message?has_content>
<div class="rounded-xl p-4 mb-6 text-sm
<#if message.type == 'error'>bg-red-500/10 border border-red-500/30 text-red-400
<#elseif message.type == 'success'>bg-green-500/10 border border-green-500/30 text-green-400
<#elseif message.type == 'warning'>bg-yellow-500/10 border border-yellow-500/30 text-yellow-400
<#else>bg-blue-500/10 border border-blue-500/30 text-blue-400</#if>">
${message.summary?no_esc}
</div>
</#if>
<form action="${url.registrationAction}" method="post" novalidate>
<!-- Nome e Cognome affiancati -->
<div class="grid grid-cols-2 gap-4 mb-5">
<div>
<label for="firstName" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Nome
</label>
<input
type="text"
id="firstName"
name="firstName"
value="${(register.firstName!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('firstName')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Mario"
autocomplete="given-name"
/>
<#if messagesPerField.existsError('firstName')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('firstName')?no_esc}</p>
</#if>
</div>
<div>
<label for="lastName" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Cognome
</label>
<input
type="text"
id="lastName"
name="lastName"
value="${(register.lastName!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('lastName')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Rossi"
autocomplete="family-name"
/>
<#if messagesPerField.existsError('lastName')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('lastName')?no_esc}</p>
</#if>
</div>
</div>
<!-- Email -->
<div class="mb-5">
<label for="email" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Email
</label>
<input
type="email"
id="email"
name="email"
value="${(register.email!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('email')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="nome@esempio.it"
autocomplete="email"
/>
<#if messagesPerField.existsError('email')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('email')?no_esc}</p>
</#if>
</div>
<!-- Username (solo se non usa email come username) -->
<#if !realm.registrationEmailAsUsername>
<div class="mb-5">
<label for="username" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value="${(register.username!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('username')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="il tuo username"
autocomplete="username"
/>
<#if messagesPerField.existsError('username')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('username')?no_esc}</p>
</#if>
</div>
</#if>
<!-- Password -->
<div class="mb-5">
<label for="password" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Password
</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('password')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Almeno 8 caratteri"
autocomplete="new-password"
/>
<button
type="button"
onclick="togglePassword('password', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
<#if messagesPerField.existsError('password')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('password')?no_esc}</p>
</#if>
</div>
<!-- Conferma password -->
<div class="mb-7">
<label for="password-confirm" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Conferma password
</label>
<div class="relative">
<input
type="password"
id="password-confirm"
name="password-confirm"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('password-confirm')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Ripeti la password"
autocomplete="new-password"
/>
<button
type="button"
onclick="togglePassword('password-confirm', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
<#if messagesPerField.existsError('password-confirm')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('password-confirm')?no_esc}</p>
</#if>
</div>
<!-- Bottone registrati -->
<button
type="submit"
class="w-full bg-cyan-500 hover:bg-cyan-400 active:bg-cyan-600 text-[#0f172a] font-semibold py-3 rounded-xl transition-colors text-sm"
>
Crea account
</button>
</form>
<!-- Link accedi -->
<p class="text-center text-[#94a3b8] mt-6 text-sm">
Hai già un account?
<a href="${url.loginUrl}"
class="text-cyan-400 hover:text-cyan-300 transition-colors font-medium ml-1">
Accedi
</a>
</p>
</div>
</div>
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const svg = btn.querySelector('svg');
if (isHidden) {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7
a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243
M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29
M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7
a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>`;
} else {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>`;
}
}
</script>
</body>
</html>
@@ -0,0 +1,2 @@
parent=keycloak
import=common/keycloak