Version 2 initial commit
0
.gitignore
vendored
Normal file
4
server/.babelrc
Executable file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["env"],
|
||||
"plugins": ["transform-object-rest-spread"]
|
||||
}
|
37
server/.env.example
Executable file
|
@ -0,0 +1,37 @@
|
|||
# Use este arquivo somente para desenvolvimento/testes
|
||||
# Em produção, estas variáveis devem estar configuradas no ambiente
|
||||
|
||||
# Usuário com privilégios para modificações
|
||||
AD_BIND_USER=ifms\usuario_para_bind
|
||||
AD_BIND_PASSWORD=senha_do_usuario_para_bind
|
||||
AD_URL=ldaps://ifms.edu.br
|
||||
AD_BASE_DN=dc=ifms,dc=edu,dc=br
|
||||
|
||||
# Funções por grupo no AD
|
||||
# Grupo para usuários Administradores do sistema (superAdmin)
|
||||
SUPER_ADMIN_GROUP=PP-PTI-Admins
|
||||
# Pode gerar tokens de redefinição de senha (tokenCreator)
|
||||
TOKEN_CREATOR_GROUP=PP-PTI-TokenCreator
|
||||
# Estudantes (student)
|
||||
STUDENT_GROUP=Estudantes
|
||||
# Servidores (Servant)
|
||||
SERVANT_GROUP=G_SERVIDORES
|
||||
# Presença Online (watcher)
|
||||
WATCHER_GROUP=PP-PTI-Watchers
|
||||
|
||||
# Altere a Variável de ambiente abaixo
|
||||
JWT_SECRET=VoceDeveAlterarEstaVariavelECYxhfBk6weKyJupHRaX2VtvZS4Fn7DdscMmrjWP9AbGqgQ3L8
|
||||
JWT_EXPIRATION=720h
|
||||
|
||||
# Prisma
|
||||
PRISMA_ENDPOINT=http://localhost:4466/
|
||||
PRISMA_SECRET=my-super-secret
|
||||
|
||||
# Playground
|
||||
PLAYGROUND=true
|
||||
|
||||
# UniFi
|
||||
UNIFI_URL=10.7.0.6
|
||||
UNIFI_PORT=8443
|
||||
UNIFI_USER=unifi_admin
|
||||
UNIFI_PASSWORD=senha_do_unifi_admin
|
7
server/.gitignore
vendored
Executable file
|
@ -0,0 +1,7 @@
|
|||
.vscode
|
||||
.idea
|
||||
.env
|
||||
config/
|
||||
dist/
|
||||
generated/
|
||||
node_modules/
|
9
server/.prettierrc
Executable file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"excludeFiles": "dist/**"
|
||||
}
|
14
server/Dockerfile
Normal file
|
@ -0,0 +1,14 @@
|
|||
FROM node
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run prisma-generate
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD npm run prisma-deploy && npm start
|
38
server/README.md
Executable file
|
@ -0,0 +1,38 @@
|
|||
# IFMS - Portal de TI - Server
|
||||
Servidor de API GraphQL para o Portal de TI do IFMS
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## Desenvolvimento
|
||||
~~~
|
||||
cp .env.example .env
|
||||
~~~
|
||||
Altere as variáveis de ambiente em `.env`
|
||||
|
||||
~~~
|
||||
docker-compose up -d
|
||||
npm run prisma-deploy # Rodar sempre que alterar o Datamodel
|
||||
npm run dev
|
||||
~~~
|
||||
|
||||
## Compilar para produção
|
||||
Altere a variável `secret:` em `prisma.yml`
|
||||
~~~
|
||||
npm run prisma-deploy
|
||||
npm run build
|
||||
~~~
|
||||
|
||||
## Produção
|
||||
Não é recomendado usar o arquivo .env em produção.
|
||||
Configure as variáveis de ambiente no servidor.
|
||||
|
||||
~~~
|
||||
npm start
|
||||
~~~
|
||||
|
||||
---
|
||||
Desenvolvido pelo SERTI Ponta Porã
|
42
server/docker-compose.yml
Executable file
|
@ -0,0 +1,42 @@
|
|||
version: '3'
|
||||
services:
|
||||
prisma:
|
||||
image: 'prismagraphql/prisma:1.34'
|
||||
restart: 'no'
|
||||
ports:
|
||||
- '4466:4466'
|
||||
environment:
|
||||
PRISMA_CONFIG: |
|
||||
port: 4466
|
||||
# uncomment the next line and provide the env var PRISMA_MANAGEMENT_API_SECRET=my-secret to activate cluster security
|
||||
# managementApiSecret: my-secret
|
||||
databases:
|
||||
default:
|
||||
connector: postgres
|
||||
host: postgres
|
||||
user: prisma
|
||||
password: prisma
|
||||
rawAccess: false
|
||||
port: 5432
|
||||
migrations: true
|
||||
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: 'no'
|
||||
environment:
|
||||
POSTGRES_USER: prisma
|
||||
POSTGRES_PASSWORD: prisma
|
||||
volumes:
|
||||
- 'postgres:/var/lib/postgresql/data'
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
restart: 'no'
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: 'admin@pg.com'
|
||||
PGADMIN_DEFAULT_PASSWORD: 'senhas'
|
||||
ports:
|
||||
- '4477:80'
|
||||
|
||||
volumes:
|
||||
postgres: null
|
9080
server/package-lock.json
generated
Executable file
51
server/package.json
Executable file
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "ifms-gql-server",
|
||||
"version": "0.0.1",
|
||||
"description": "Servidor GraphQL para concentrar as informações do IFMS",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon --ext js,graphql src/index.js --exec babel-node",
|
||||
"prisma-deploy": "prisma deploy",
|
||||
"prisma-generate": "prisma generate",
|
||||
"build": "babel src --out-dir dist --copy-files"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/douglasvbarone/ifms-gql-server.git"
|
||||
},
|
||||
"keywords": [
|
||||
"ifms"
|
||||
],
|
||||
"author": "Douglas Barone",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/douglasvbarone/ifms-gql-server/issues"
|
||||
},
|
||||
"homepage": "https://github.com/douglasvbarone/ifms-gql-server#readme",
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"nodemon": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/polyfill": "^7.8.3",
|
||||
"activedirectory2": "^1.3.0",
|
||||
"apollo-server": "^2.9.16",
|
||||
"apollo-server-plugin-response-cache": "^0.4.1",
|
||||
"bcrypt": "^3.0.7",
|
||||
"dotenv": "^8.2.0",
|
||||
"graphql": "^14.6.0",
|
||||
"graphql-tools": "^4.0.6",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"ldapjs": "^1.0.2",
|
||||
"moment": "^2.24.0",
|
||||
"node-cron": "^2.0.3",
|
||||
"node-unifi": "^1.2.2",
|
||||
"prisma": "^1.34.10",
|
||||
"prisma-binding": "^2.3.16",
|
||||
"prisma-client-lib": "^1.34.10",
|
||||
"uuid": "^3.4.0"
|
||||
}
|
||||
}
|
10
server/prisma.yml
Executable file
|
@ -0,0 +1,10 @@
|
|||
endpoint: ${env:PRISMA_ENDPOINT}
|
||||
datamodel:
|
||||
- src/datamodels/user.prisma
|
||||
- src/datamodels/resetToken.prisma
|
||||
- src/datamodels/wifiDevice.prisma
|
||||
secret: ${env:PRISMA_SECRET}
|
||||
|
||||
generate:
|
||||
- generator: javascript-client
|
||||
output: ./src/generated/prisma-client/
|
96
server/src/classes/ResetToken.js
Executable file
|
@ -0,0 +1,96 @@
|
|||
import { prisma } from '../generated/prisma-client'
|
||||
import uuid from 'uuid/v4'
|
||||
import { User } from './User'
|
||||
import moment from 'moment'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { replacePassword } from '../utils/activedirectory/passwordUtils'
|
||||
|
||||
class ResetToken {
|
||||
/**
|
||||
* Generates a reset token for a specific user
|
||||
* @param username
|
||||
* @param creatorUsername
|
||||
* @returns {ResetTokenPromise}
|
||||
*/
|
||||
static async createToken(username, creatorUsername) {
|
||||
const user = await new User(username).init()
|
||||
|
||||
if (!user.roles.includes('student'))
|
||||
throw new Error('Apenas estudantes podem utilizar tokens')
|
||||
|
||||
const token = uuid()
|
||||
const expiration = moment(new Date()).add(3, 'days')
|
||||
|
||||
const hashedToken = await prisma.createResetToken({
|
||||
token: await bcrypt.hash(token, 2),
|
||||
creator: {
|
||||
connect: { sAMAccountName: creatorUsername }
|
||||
},
|
||||
user: {
|
||||
connect: { id: user.id }
|
||||
},
|
||||
expiration: expiration
|
||||
})
|
||||
|
||||
return {
|
||||
...hashedToken,
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a reset token exists and is valid
|
||||
* @param token
|
||||
* @returns {Promise<ResetToken>}
|
||||
*/
|
||||
static async checkToken(token) {
|
||||
const hashedResetTokens = await prisma.resetTokens({
|
||||
where: {
|
||||
expiration_gt: new Date(),
|
||||
usedAt: null
|
||||
}
|
||||
})
|
||||
|
||||
if (hashedResetTokens.length === 0) return null
|
||||
|
||||
const checkedTokens = await Promise.all(
|
||||
hashedResetTokens.map(async hashedToken => ({
|
||||
...hashedToken,
|
||||
valid: await bcrypt.compare(token, hashedToken.token)
|
||||
}))
|
||||
)
|
||||
|
||||
return checkedTokens.find(v => v.valid)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param token
|
||||
* @param newPassword
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async useToken(token, newPassword) {
|
||||
const resetToken = await ResetToken.checkToken(token)
|
||||
|
||||
if (!resetToken) {
|
||||
throw new Error('Token inválido, já usado ou expirado')
|
||||
}
|
||||
|
||||
const user = await prisma.resetToken({ id: resetToken.id }).user()
|
||||
|
||||
await replacePassword(user.sAMAccountName, newPassword)
|
||||
|
||||
await prisma.updateResetToken({
|
||||
where: {
|
||||
id: resetToken.id
|
||||
},
|
||||
data: {
|
||||
usedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export { ResetToken }
|
328
server/src/classes/User.js
Executable file
|
@ -0,0 +1,328 @@
|
|||
import { ad } from '../utils/activedirectory'
|
||||
import { prisma } from '../generated/prisma-client'
|
||||
import { Change, createClient } from 'ldapjs'
|
||||
|
||||
import { encodePassword } from '../utils/activedirectory/encodePassword'
|
||||
import config from '../utils/activedirectory/config'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
class User {
|
||||
constructor(username) {
|
||||
this.username = username
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the object, since asynchronous tasks can't run in the constructor
|
||||
* @return {Promise<User>}
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
let user = await prisma.user({ sAMAccountName: this.username })
|
||||
|
||||
if (!user || !user.roles || !user.groups)
|
||||
user = await User.upsertUser(this.username)
|
||||
else User.upsertUser(this.username)
|
||||
|
||||
Object.assign(this, user)
|
||||
|
||||
return this
|
||||
} catch (e) {
|
||||
throw new Error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user is a Super Admin
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
static async isSuperAdmin(username) {
|
||||
const superAdminGroup = process.env.SUPER_ADMIN_GROUP || 'PP-SERTI'
|
||||
return ad.isUserMemberOf(username, superAdminGroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user is a Token Generator
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
static async isTokenCreator(username) {
|
||||
const tokenCreatorGroup = process.env.TOKEN_CREATOR_GROUP || 'CEREL'
|
||||
return await ad.isUserMemberOf(username, tokenCreatorGroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user is a Servant
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
static async isServant(username) {
|
||||
const servantGroup = process.env.SERVANT_GROUP || 'G_SERVIDORES'
|
||||
return ad.isUserMemberOf(username, servantGroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user is a Student
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
static async isStudent(username) {
|
||||
const studentGroup = process.env.STUDENT_GROUP || 'Estudantes'
|
||||
return ad.isUserMemberOf(username, studentGroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the a user role(s)
|
||||
* @param username
|
||||
* @returns {Promise<[String]>}
|
||||
*/
|
||||
static async getRoles(username, groups = null) {
|
||||
const rolesGroups = [
|
||||
{
|
||||
role: 'superAdmin',
|
||||
adGroup: process.env.SUPER_ADMIN_GROUP || 'PP-SERTI'
|
||||
},
|
||||
{
|
||||
role: 'tokenCreator',
|
||||
adGroup: process.env.TOKEN_CREATOR_GROUP || 'PTI-TokenGen'
|
||||
},
|
||||
{
|
||||
role: 'student',
|
||||
adGroup: process.env.STUDENT_GROUP || 'Estudantes'
|
||||
},
|
||||
{
|
||||
role: 'servant',
|
||||
adGroup: process.env.SERVANT_GROUP || 'G_SERVIDORES'
|
||||
},
|
||||
{
|
||||
role: 'watcher',
|
||||
adGroup: process.env.WATCHER_GROUP || 'PTI-Vigias'
|
||||
}
|
||||
]
|
||||
|
||||
const userGroups = groups || (await User.getGroupsMembership(username))
|
||||
|
||||
const userRoles = rolesGroups
|
||||
.filter(roleGroup =>
|
||||
userGroups.some(userGroup => userGroup.cn === roleGroup.adGroup)
|
||||
)
|
||||
.map(role => role.role)
|
||||
|
||||
if (userRoles.some(role => role === 'superAdmin'))
|
||||
userRoles.push('tokenCreator', 'watcher')
|
||||
|
||||
return [...new Set(userRoles)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user has group membership
|
||||
* @param {String} group
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
async isMemberOf(group) {
|
||||
return ad.isUserMemberOf(this.username, group)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user password.
|
||||
* @param oldPassword
|
||||
* @param newPassword
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
updatePassword(oldPassword, newPassword) {
|
||||
if (oldPassword === newPassword)
|
||||
throw new Error('A nova senha não pode ser igual à antiga')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = createClient(config)
|
||||
|
||||
client.on('error', err => {
|
||||
client.unbind(() => {
|
||||
console.log('Client unbinded due error.')
|
||||
})
|
||||
reject(new Error(err.message))
|
||||
})
|
||||
|
||||
client.bind(`ifms\\${this.username}`, oldPassword, (err, result) => {
|
||||
if (err) {
|
||||
client.bind(config.username, config.password, (err, result) => {
|
||||
if (err) reject(new Error('Problemas técnicos, tente mais tarde'))
|
||||
else reject(new Error('Usuário ou senha antiga incorretos.'))
|
||||
})
|
||||
} else
|
||||
client.search(
|
||||
config.baseDN,
|
||||
{
|
||||
filter: `(sAMAccountName=${this.username})`,
|
||||
attributes: 'dn',
|
||||
scope: 'sub'
|
||||
},
|
||||
(err, res) => {
|
||||
res.on('error', err => {
|
||||
reject(new Error(err.message))
|
||||
})
|
||||
|
||||
res.on('searchEntry', entry => {
|
||||
const userDN = entry.object.dn
|
||||
|
||||
client.bind(userDN, oldPassword, () => {
|
||||
client.modify(
|
||||
userDN,
|
||||
[
|
||||
new Change({
|
||||
operation: 'delete',
|
||||
modification: {
|
||||
unicodePwd: encodePassword(oldPassword)
|
||||
}
|
||||
}),
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: {
|
||||
unicodePwd: encodePassword(newPassword)
|
||||
}
|
||||
})
|
||||
],
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
client.unbind(() => {
|
||||
console.log('Client unbinded due error.')
|
||||
})
|
||||
reject(new Error(err.message))
|
||||
} else {
|
||||
User.upsertUser(this.username)
|
||||
client.unbind(() => {
|
||||
console.log('Client unbinded')
|
||||
})
|
||||
resolve(User.login(this.username, newPassword))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
static async getGroupsMembership(username) {
|
||||
return ad.getGroupMembershipForUser(
|
||||
{ attributes: ['cn', 'dn', 'name'] },
|
||||
username
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a user in the database
|
||||
* @return {Object}
|
||||
*/
|
||||
static async upsertUser(username) {
|
||||
const adUser = await ad.findUser(username)
|
||||
if (!adUser) throw new Error('Usuário não encontrado')
|
||||
|
||||
const groups = await User.getGroupsMembership(username)
|
||||
const roles = await User.getRoles(username, groups)
|
||||
|
||||
const user = {
|
||||
...adUser,
|
||||
roles,
|
||||
groups
|
||||
}
|
||||
|
||||
return prisma.upsertUser({
|
||||
where: {
|
||||
sAMAccountName: user.sAMAccountName
|
||||
},
|
||||
update: user,
|
||||
create: user
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attemps to login user and returns AuthPayload data
|
||||
* @param username
|
||||
* @param password
|
||||
* @returns {Promise<{expiresIn: String, user: User, token: String}>}
|
||||
*/
|
||||
static async login(username, password) {
|
||||
try {
|
||||
await ad.authenticate('ifms\\' + username, password)
|
||||
} catch (err) {
|
||||
throw (await ad.checkBinding())
|
||||
? new Error('Usuário ou senha inválidos.')
|
||||
: new Error('Problemas técnicos ao autenticar. Tente mais tarde.')
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await new User(username).init()
|
||||
|
||||
await prisma.updateUser({
|
||||
where: { sAMAccountName: user.sAMAccountName },
|
||||
data: {
|
||||
lastLoginPrior: user.lastLogin || new Date(),
|
||||
lastLogin: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
const payload = {
|
||||
sAMAccountName: user.sAMAccountName,
|
||||
pwdLastSet: user.pwdLastSet
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || 'a good secret'
|
||||
|
||||
//@TODO Add momentJs to calculate expiration
|
||||
const options = { expiresIn: process.env.JWT_EXPIRATION || '72h' }
|
||||
|
||||
const token = jwt.sign(payload, jwtSecret, options)
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
expiresIn: options.expiresIn
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'Erro ao atualizar dados do usuário. Informe o departamento de TI.'
|
||||
) // is DB connection OK?!
|
||||
}
|
||||
}
|
||||
|
||||
static async importAllUsers() {
|
||||
try {
|
||||
console.log('Obtendo usuários do AD...')
|
||||
|
||||
const allAdUsers = await ad.findUsers({
|
||||
paged: true,
|
||||
filter: '(!(userAccountControl:1.2.840.113556.1.4.803:=2))' // Only active users
|
||||
})
|
||||
|
||||
// const allAdUsers = await ad.getUsersForGroup('Estudantes')
|
||||
// const allAdUsers = [await ad.findUser('aluno.teste')]
|
||||
|
||||
const dbUsers = []
|
||||
let i = 0
|
||||
|
||||
for (let user of allAdUsers) {
|
||||
const dbUser = await prisma.upsertUser({
|
||||
where: {
|
||||
sAMAccountName: user.sAMAccountName
|
||||
},
|
||||
update: user,
|
||||
create: user
|
||||
})
|
||||
|
||||
dbUsers.push(dbUser)
|
||||
i++
|
||||
|
||||
process.stdout.write(
|
||||
`\rImportando usuário ${i} de ${allAdUsers.length}.`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nOK!')
|
||||
|
||||
return dbUsers
|
||||
} catch (e) {
|
||||
throw new Error('Erro ao importar usuários: ' + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { User }
|
35
server/src/cronTasks.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import cron from 'node-cron'
|
||||
import { updateDBWithOnlineDevices } from './utils/wifiUtils'
|
||||
import { User } from './classes/User'
|
||||
|
||||
console.log('Scheduling Tasks...')
|
||||
|
||||
cron.schedule(
|
||||
'*/5 * * * *',
|
||||
async () => {
|
||||
console.log(
|
||||
'cron exec: updateDBWithOnlineDevices started at' +
|
||||
new Date().toTimeString()
|
||||
)
|
||||
await updateDBWithOnlineDevices()
|
||||
console.log(
|
||||
'cron exec: updateDBWithOnlineDevices finished at' +
|
||||
new Date().toTimeString()
|
||||
)
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
cron.schedule(
|
||||
'0 0 0 * * *',
|
||||
async () => {
|
||||
console.log(
|
||||
'cron exec: User.importAllUsers started at ' + new Date().toTimeString()
|
||||
)
|
||||
await User.importAllUsers()
|
||||
console.log(
|
||||
'cron exec: User.importAllUsers finished at ' + new Date().toTimeString()
|
||||
)
|
||||
},
|
||||
{}
|
||||
)
|
12
server/src/datamodels/resetToken.prisma
Executable file
|
@ -0,0 +1,12 @@
|
|||
type ResetToken {
|
||||
id: ID! @id
|
||||
user: User! @relation(name: "ResetTokenToUser", onDelete: SET_NULL)
|
||||
creator: User! @relation(name: "ResetTokenToCreator", onDelete: SET_NULL)
|
||||
token: String! @unique
|
||||
expiration: DateTime!
|
||||
|
||||
usedAt: DateTime
|
||||
|
||||
createdAt: DateTime! @createdAt
|
||||
updatedAt: DateTime! @updatedAt
|
||||
}
|
54
server/src/datamodels/user.prisma
Executable file
|
@ -0,0 +1,54 @@
|
|||
type User {
|
||||
id: ID! @id
|
||||
lastLogin: DateTime
|
||||
lastLoginPrior: DateTime
|
||||
roles: Json
|
||||
groups: Json
|
||||
|
||||
resetTokens: [ResetToken!] @relation(name: "ResetTokenToUser", onDelete: CASCADE)
|
||||
createdResetTokens: [ResetToken!] @relation(name: "ResetTokenToCreator", onDelete: CASCADE)
|
||||
wifiDevices: [WifiDevice!] @relation(name: "WifiDeviceToUser")
|
||||
|
||||
createdAt: DateTime! @createdAt
|
||||
updatedAt: DateTime! @updatedAt
|
||||
|
||||
#AD fields
|
||||
accountExpires: String
|
||||
badPasswordTime: String
|
||||
badPwdCount: String
|
||||
cn: String
|
||||
department: String
|
||||
description: String
|
||||
displayName: String
|
||||
distinguishedName: String
|
||||
dn: String
|
||||
extensionAttribute1: String
|
||||
extensionAttribute10: String
|
||||
extensionAttribute2: String
|
||||
extensionAttribute6: String
|
||||
extensionAttribute7: String
|
||||
givenName: String
|
||||
homeDirectory: String
|
||||
homeDrive: String
|
||||
lastLogoff: String
|
||||
lastLogon: String
|
||||
lastLogonTimestamp: String
|
||||
lockoutTime: String
|
||||
logonCount: String
|
||||
mail: String
|
||||
name: String
|
||||
objectCategory: String
|
||||
objectGUID: String
|
||||
objectSid: String
|
||||
primaryGroupID: String
|
||||
pwdLastSet: String
|
||||
sAMAccountName: String! @unique
|
||||
sAMAccountType: String
|
||||
sn: String
|
||||
thumbnailPhoto: String
|
||||
title: String
|
||||
userAccountControl: String
|
||||
userPrincipalName: String
|
||||
whenChanged: String
|
||||
whenCreated: String
|
||||
}
|
22
server/src/datamodels/wifiDevice.prisma
Normal file
|
@ -0,0 +1,22 @@
|
|||
type WifiDevice {
|
||||
id: ID! @id
|
||||
user: User @relation(name: "WifiDeviceToUser")
|
||||
oui: String
|
||||
mac: String @unique
|
||||
hostname: String
|
||||
firstSeen: String
|
||||
lastSeen: String
|
||||
essid: String
|
||||
ip: String
|
||||
uptime: String
|
||||
apName: String
|
||||
status: Status
|
||||
|
||||
createdAt: DateTime! @createdAt
|
||||
updatedAt: DateTime! @updatedAt
|
||||
}
|
||||
|
||||
enum Status {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
}
|
21
server/src/index.js
Executable file
|
@ -0,0 +1,21 @@
|
|||
import {} from 'dotenv/config'
|
||||
import '@babel/polyfill/noConflict'
|
||||
import './utils/capitalize'
|
||||
|
||||
import { server } from './server'
|
||||
|
||||
import './cronTasks'
|
||||
|
||||
console.log(
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'Running in production'
|
||||
: 'Running in development'
|
||||
)
|
||||
|
||||
server.listen().then(options => {
|
||||
console.log(
|
||||
`\n---\nServer ready!`,
|
||||
`\nEndpoint: ${options.url}graphql:${options.port} `,
|
||||
`\nWebSocket: ${options.subscriptionsUrl}\n---`
|
||||
)
|
||||
})
|
5
server/src/resolvers/Group.js
Executable file
|
@ -0,0 +1,5 @@
|
|||
const Group = {
|
||||
members: (_, args, { ad }) => ad.getUsersForGroup(_.cn)
|
||||
}
|
||||
|
||||
export { Group }
|
35
server/src/resolvers/Mutation.js
Executable file
|
@ -0,0 +1,35 @@
|
|||
import { replacePassword } from '../utils/activedirectory/passwordUtils'
|
||||
import { User } from '../classes/User'
|
||||
import { ResetToken } from '../classes/ResetToken'
|
||||
|
||||
const Mutation = {
|
||||
async login(_, { data }) {
|
||||
return User.login(data.username, data.password)
|
||||
},
|
||||
|
||||
async updatePassword(_, { data }, { auth }) {
|
||||
return auth.updatePassword(data.oldPassword, data.newPassword)
|
||||
},
|
||||
|
||||
async replacePassword(_, { data }) {
|
||||
return replacePassword(data.username, data.newPassword)
|
||||
},
|
||||
|
||||
async createResetToken(_, { data }, { auth }) {
|
||||
return ResetToken.createToken(data.username, auth.sAMAccountName)
|
||||
},
|
||||
|
||||
async useResetToken(_, { data }) {
|
||||
return ResetToken.useToken(data.token, data.newPassword)
|
||||
},
|
||||
|
||||
async importUsers() {
|
||||
User.importAllUsers()
|
||||
.then(users => console.log('OK', users.length))
|
||||
.catch(console.log)
|
||||
|
||||
return 'A importação está sendo feita. Isso pode demorar alguns minutos.'
|
||||
}
|
||||
}
|
||||
|
||||
export { Mutation }
|
155
server/src/resolvers/Query.js
Executable file
|
@ -0,0 +1,155 @@
|
|||
import { prisma } from '../generated/prisma-client'
|
||||
import { updateDBWithOnlineDevices } from '../utils/wifiUtils'
|
||||
import { User } from '../classes/User'
|
||||
import { gql } from 'apollo-server'
|
||||
|
||||
const parseSAMAccountName = sAMAccountName =>
|
||||
sAMAccountName ? sAMAccountName.replace('.', ' ') : ''
|
||||
|
||||
const Query = {
|
||||
async me(_, args, { auth }) {
|
||||
return auth
|
||||
},
|
||||
|
||||
async basicUser(_, { sAMAccountName }) {
|
||||
if (sAMAccountName === '') throw new Error('Argumento inválido')
|
||||
|
||||
const user = await new User(sAMAccountName).init()
|
||||
|
||||
if (!user) throw new Error('Usuário não encontrado.')
|
||||
|
||||
return {
|
||||
sAMAccountName: user.sAMAccountName,
|
||||
displayName: user.displayName,
|
||||
thumbnailPhoto: user.thumbnailPhoto,
|
||||
roles: user.roles
|
||||
}
|
||||
},
|
||||
|
||||
async users(
|
||||
_,
|
||||
{ where: { cn, displayName, sAMAccountName }, limit, onlyStudents },
|
||||
{ ad }
|
||||
) {
|
||||
const parsedSAMAccountName = parseSAMAccountName(sAMAccountName)
|
||||
|
||||
const studentGroup = process.env.STUDENT_GROUP || 'Estudantes'
|
||||
|
||||
const search = `|(cn=*${parsedSAMAccountName}*)(cn=*${cn}*)(displayName=*${displayName}*)`
|
||||
|
||||
const filter = onlyStudents
|
||||
? `(&(memberOf=CN=${studentGroup},OU=Groups,DC=ifms,DC=edu,DC=br)(${search}))`
|
||||
: `(${search})`
|
||||
|
||||
try {
|
||||
const users = await ad.findUsers({
|
||||
filter,
|
||||
sizeLimit: limit
|
||||
})
|
||||
|
||||
return users.map(user => ({ ...user, roles: [] })) || []
|
||||
} catch (e) {
|
||||
throw new Error('Não foi possível realizar a busca.')
|
||||
}
|
||||
},
|
||||
|
||||
async user(_, { sAMAccountName }) {
|
||||
if (!sAMAccountName)
|
||||
throw new Error('Busca vazia. Informe uma conta de usuário.')
|
||||
try {
|
||||
const user = await new User(sAMAccountName).init()
|
||||
return user || new Error('Usuário não encontrado')
|
||||
} catch (e) {
|
||||
throw new Error(e.message)
|
||||
}
|
||||
},
|
||||
|
||||
async groups(_, { where, limit }, { ad }) {
|
||||
const groups = await ad.findGroups({
|
||||
filter: `(|(cn=*${where.cn}*)(name=*${where.name}*)(dn=*${where.dn}*))`,
|
||||
sizeLimit: limit
|
||||
})
|
||||
|
||||
return groups || []
|
||||
},
|
||||
|
||||
async stats() {
|
||||
return {
|
||||
tokenCountTotal: prisma.resetTokensConnection().aggregate().count(),
|
||||
tokenCountUsed: prisma
|
||||
.resetTokensConnection({ where: { usedAt_not: null } })
|
||||
.aggregate()
|
||||
.count(),
|
||||
tokenCountExpired: prisma
|
||||
.resetTokensConnection({ where: { expiration_lte: new Date() } })
|
||||
.aggregate()
|
||||
.count(),
|
||||
tokenCountNotUsed: prisma
|
||||
.resetTokensConnection({ where: { usedAt: null } })
|
||||
.aggregate()
|
||||
.count()
|
||||
}
|
||||
},
|
||||
|
||||
wifiDevices: async (_, { identifiedOnly }) => {
|
||||
await updateDBWithOnlineDevices()
|
||||
// updateDBWithOnlineDevices().then(success=>{
|
||||
// if(success)
|
||||
// console.log('PubSub') //TODO: PubSub
|
||||
// })
|
||||
|
||||
return prisma.wifiDevices({
|
||||
orderBy: 'lastSeen_DESC',
|
||||
where: identifiedOnly ? { NOT: { user: null } } : {}
|
||||
})
|
||||
},
|
||||
userPresence: async (_, { search }) => {
|
||||
if (!search) {
|
||||
await updateDBWithOnlineDevices()
|
||||
search = ''
|
||||
}
|
||||
|
||||
const usersWithWifiDevices = await prisma.users({
|
||||
where: {
|
||||
wifiDevices_some: {
|
||||
id_not: null
|
||||
}
|
||||
}
|
||||
}).$fragment(gql`
|
||||
fragment HydrateUser on User {
|
||||
id
|
||||
displayName
|
||||
thumbnailPhoto
|
||||
wifiDevices {
|
||||
id
|
||||
status
|
||||
lastSeen
|
||||
apName
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const userPresences = usersWithWifiDevices
|
||||
.filter(user =>
|
||||
user.displayName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.map(user => ({
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
thumbnailPhoto: user.thumbnailPhoto
|
||||
},
|
||||
wifiDevices: user.wifiDevices.sort((a, b) =>
|
||||
a.lastSeen > b.lastSeen ? -1 : 1
|
||||
)
|
||||
}))
|
||||
|
||||
const sortedUserPresences = userPresences.sort((a, b) =>
|
||||
a.wifiDevices[0].lastSeen > b.wifiDevices[0].lastSeen ? -1 : 1
|
||||
)
|
||||
|
||||
return sortedUserPresences.slice(0, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
export { Query }
|
12
server/src/resolvers/ResetToken.js
Executable file
|
@ -0,0 +1,12 @@
|
|||
import { prisma } from '../generated/prisma-client'
|
||||
|
||||
const ResetToken = {
|
||||
creator(_) {
|
||||
return prisma.resetToken({ id: _.id }).creator()
|
||||
},
|
||||
user(_) {
|
||||
return prisma.resetToken({ id: _.id }).user()
|
||||
}
|
||||
}
|
||||
|
||||
export { ResetToken }
|
48
server/src/resolvers/User.js
Executable file
|
@ -0,0 +1,48 @@
|
|||
import { prisma } from '../generated/prisma-client'
|
||||
|
||||
const User = {
|
||||
firstName: _ => _.displayName.split(' ')[0],
|
||||
displayName: _ => (_.displayName ? _.displayName.capitalize() : ''),
|
||||
|
||||
groups: async (_, args, { ad }) =>
|
||||
_.groups ? _.groups : ad.getGroupMembershipForUser(_.sAMAccountName),
|
||||
|
||||
sharedFolders: _ =>
|
||||
_.groups
|
||||
? _.groups
|
||||
.filter(group => group.cn.includes('-Share-'))
|
||||
.map(group => group.cn.split('-')[2])
|
||||
: [],
|
||||
|
||||
sharedPrinters: _ =>
|
||||
_.groups
|
||||
? _.groups
|
||||
.filter(group => group.cn.includes('-Printer-'))
|
||||
.map(group => group.cn.split('-')[2])
|
||||
: [],
|
||||
|
||||
isSuperAdmin: _ => _.roles.includes('superAdmin'),
|
||||
isTokenCreator: _ => _.roles.includes('tokenCreator'),
|
||||
isServant: _ => _.roles.includes('servant'),
|
||||
isStudent: _ => _.roles.includes('student'),
|
||||
isWatcher: _ => _.roles.includes('watcher'),
|
||||
|
||||
wifiDevices: (_, data, { auth }) => {
|
||||
if (
|
||||
_.sAMAccountName !== auth.sAMAccountName &&
|
||||
!auth.roles.includes('superAdmin')
|
||||
)
|
||||
return []
|
||||
|
||||
return prisma.wifiDevices({
|
||||
where: {
|
||||
user: {
|
||||
sAMAccountName: _.sAMAccountName
|
||||
}
|
||||
},
|
||||
orderBy: 'lastSeen_DESC'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { User }
|
7
server/src/resolvers/WifiDevice.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { prisma } from '../generated/prisma-client'
|
||||
|
||||
const WifiDevice = {
|
||||
user: _ => prisma.wifiDevice({ id: _.id }).user()
|
||||
}
|
||||
|
||||
export { WifiDevice }
|
18
server/src/resolvers/index.js
Executable file
|
@ -0,0 +1,18 @@
|
|||
import { Query } from './Query'
|
||||
import { Mutation } from './Mutation'
|
||||
|
||||
import { User } from './User'
|
||||
import { Group } from './Group'
|
||||
import { ResetToken } from './ResetToken'
|
||||
import { WifiDevice } from './WifiDevice'
|
||||
|
||||
const resolvers = {
|
||||
Query,
|
||||
Mutation,
|
||||
User,
|
||||
Group,
|
||||
ResetToken,
|
||||
WifiDevice
|
||||
}
|
||||
|
||||
export { resolvers }
|
47
server/src/schemaDirectives/AuthDirective.js
Executable file
|
@ -0,0 +1,47 @@
|
|||
import { SchemaDirectiveVisitor } from 'apollo-server'
|
||||
import { defaultFieldResolver } from 'graphql'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
import { User } from '../classes/User'
|
||||
|
||||
class AuthDirective extends SchemaDirectiveVisitor {
|
||||
visitFieldDefinition(field, details) {
|
||||
const { resolve = defaultFieldResolver } = field
|
||||
|
||||
field.resolve = async (...args) => {
|
||||
const [, , context] = args
|
||||
|
||||
const { roles: expectedRoles = [] } = this.args
|
||||
|
||||
const authorizationHeader = context.req
|
||||
? context.req.headers.authorization
|
||||
: context.connection.context.Authorization // TODO: check if work with subscriptions
|
||||
|
||||
if (authorizationHeader) {
|
||||
const token = authorizationHeader.replace('Bearer ', '')
|
||||
const { sAMAccountName, pwdLastSet } = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET
|
||||
)
|
||||
|
||||
const user = await new User(sAMAccountName).init()
|
||||
|
||||
context.auth = user
|
||||
|
||||
if (user.pwdLastSet === pwdLastSet) {
|
||||
if (
|
||||
expectedRoles.length === 0 ||
|
||||
expectedRoles.some(role => user.roles.includes(role))
|
||||
)
|
||||
return resolve.apply(this, args)
|
||||
|
||||
throw new Error('Você não tem permissão para fazer isso')
|
||||
} else throw new Error('O token utilizado foi invalidado')
|
||||
}
|
||||
|
||||
throw new Error('É necessário fazer login')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthDirective }
|
7
server/src/schemaDirectives/index.js
Executable file
|
@ -0,0 +1,7 @@
|
|||
import { AuthDirective } from './AuthDirective'
|
||||
|
||||
const schemaDirectives = {
|
||||
auth: AuthDirective
|
||||
}
|
||||
|
||||
export { schemaDirectives }
|
36
server/src/server.js
Executable file
|
@ -0,0 +1,36 @@
|
|||
import { ApolloServer } from 'apollo-server'
|
||||
import responseCachePlugin from 'apollo-server-plugin-response-cache'
|
||||
|
||||
import { prisma as db } from './generated/prisma-client'
|
||||
import { ad } from './utils/activedirectory'
|
||||
|
||||
import { typeDefs } from './typeDefs'
|
||||
import { resolvers } from './resolvers'
|
||||
import { schemaDirectives } from './schemaDirectives'
|
||||
|
||||
const server = new ApolloServer({
|
||||
cors:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? {
|
||||
origin: ['http://ti.pp.ifms.edu.br']
|
||||
}
|
||||
: true,
|
||||
typeDefs,
|
||||
resolvers,
|
||||
schemaDirectives,
|
||||
context: ({ req }) => {
|
||||
return {
|
||||
db,
|
||||
ad,
|
||||
req
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
responseCachePlugin({
|
||||
sessionId: requestContext =>
|
||||
requestContext.request.http.headers.get('Authorization') || null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
export { server }
|
202
server/src/typeDefs.js
Normal file
|
@ -0,0 +1,202 @@
|
|||
import { gql } from 'apollo-server'
|
||||
|
||||
const typeDefs = gql`
|
||||
type Query {
|
||||
"Returns only a few fields of User"
|
||||
basicUser(sAMAccountName: String!): User! @cacheControl(maxAge: 350)
|
||||
|
||||
me: User! @auth @cacheControl(maxAge: 30, scope: PRIVATE)
|
||||
|
||||
users(
|
||||
where: UserWhereInput!
|
||||
limit: Int = 15
|
||||
onlyStudents: Boolean = false
|
||||
): [User!] @auth(roles: ["servant"]) @cacheControl(maxAge: 350)
|
||||
|
||||
user(sAMAccountName: String!): User!
|
||||
@auth(roles: ["superAdmin"])
|
||||
@cacheControl(maxAge: 350)
|
||||
|
||||
groups(where: GroupWhereInput!, limit: Int = 10): [Group!]!
|
||||
@auth(roles: ["servant"])
|
||||
@cacheControl(maxAge: 350)
|
||||
|
||||
stats: Stats!
|
||||
|
||||
wifiDevices(
|
||||
search: String = ""
|
||||
identifiedOnly: Boolean = true
|
||||
): [WifiDevice]!
|
||||
|
||||
userPresence(search: String): [UserPresence!] @auth(roles: ["watcher"])
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
login(data: LoginInput!): AuthPayload!
|
||||
updatePassword(data: UpdatePasswordInput!): AuthPayload! @auth
|
||||
replacePassword(data: ReplacePasswordInput!): String!
|
||||
@auth(roles: ["superAdmin"])
|
||||
createResetToken(data: CreateResetTokenInput!): ResetToken!
|
||||
@auth(roles: ["superAdmin", "tokenCreator"])
|
||||
useResetToken(data: UseResetTokenInput!): Boolean!
|
||||
importUsers: String! @auth(roles: ["superAdmin"])
|
||||
}
|
||||
|
||||
directive @auth(roles: [String!]) on FIELD_DEFINITION
|
||||
|
||||
"A mix between the database User and the Active Directory User"
|
||||
type User @cacheControl(maxAge: 350, scope: PRIVATE) {
|
||||
id: ID
|
||||
|
||||
wifiDevices: [WifiDevice!]
|
||||
|
||||
lastLogin: String
|
||||
lastLoginPrior: String
|
||||
roles: [String!]
|
||||
groups: [Group!]
|
||||
sharedFolders: [String!]
|
||||
sharedPrinters: [String!]
|
||||
|
||||
firstName: String
|
||||
|
||||
isSuperAdmin: Boolean!
|
||||
isTokenCreator: Boolean!
|
||||
isServant: Boolean!
|
||||
isStudent: Boolean!
|
||||
isWatcher: Boolean!
|
||||
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
|
||||
accountExpires: String
|
||||
badPasswordTime: String
|
||||
badPwdCount: String
|
||||
cn: String
|
||||
department: String
|
||||
description: String
|
||||
displayName: String
|
||||
distinguishedName: String
|
||||
dn: String
|
||||
extensionAttribute1: String
|
||||
extensionAttribute10: String
|
||||
extensionAttribute2: String
|
||||
extensionAttribute6: String
|
||||
extensionAttribute7: String
|
||||
givenName: String
|
||||
homeDirectory: String
|
||||
homeDrive: String
|
||||
lastLogon: String
|
||||
lastLogonTimestamp: String
|
||||
lockoutTime: String
|
||||
logonCount: String
|
||||
mail: String
|
||||
name: String
|
||||
objectCategory: String
|
||||
objectGUID: String
|
||||
objectSid: String
|
||||
primaryGroupID: String
|
||||
pwdLastSet: String
|
||||
sAMAccountName: String!
|
||||
sAMAccountType: String
|
||||
sn: String
|
||||
thumbnailPhoto: String
|
||||
title: String
|
||||
userAccountControl: String
|
||||
userPrincipalName: String
|
||||
whenChanged: String
|
||||
whenCreated: String
|
||||
}
|
||||
|
||||
type Group {
|
||||
cn: String!
|
||||
dn: String!
|
||||
name: String
|
||||
members: [User!]!
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
user: User!
|
||||
token: String!
|
||||
expiresIn: String!
|
||||
}
|
||||
|
||||
type ResetToken {
|
||||
id: ID!
|
||||
user: User!
|
||||
creator: User!
|
||||
token: String!
|
||||
expiration: String!
|
||||
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Stats {
|
||||
tokenCountTotal: Int!
|
||||
tokenCountUsed: Int!
|
||||
tokenCountExpired: Int!
|
||||
tokenCountNotUsed: Int!
|
||||
}
|
||||
|
||||
type WifiDevice {
|
||||
user: User
|
||||
id: String
|
||||
oui: String
|
||||
mac: String
|
||||
hostname: String
|
||||
firstSeen: String
|
||||
lastSeen: String
|
||||
essid: String
|
||||
ip: String
|
||||
uptime: String
|
||||
apName: String
|
||||
status: Status
|
||||
}
|
||||
|
||||
type UserPresence {
|
||||
user: User!
|
||||
wifiDevices: [WifiDevice!]!
|
||||
}
|
||||
|
||||
enum Status {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
}
|
||||
|
||||
input LoginInput {
|
||||
username: String!
|
||||
password: String!
|
||||
}
|
||||
input UpdatePasswordInput {
|
||||
oldPassword: String!
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
input ReplacePasswordInput {
|
||||
username: String!
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
input UserWhereInput {
|
||||
cn: String
|
||||
displayName: String
|
||||
sAMAccountName: String
|
||||
}
|
||||
|
||||
input GroupWhereInput {
|
||||
cn: String
|
||||
dn: String
|
||||
name: String
|
||||
}
|
||||
|
||||
input CreateResetTokenInput {
|
||||
username: String!
|
||||
}
|
||||
|
||||
input UseResetTokenInput {
|
||||
token: String!
|
||||
newPassword: String!
|
||||
}
|
||||
`
|
||||
|
||||
export { typeDefs }
|
44
server/src/utils/activedirectory/attributes.js
Executable file
|
@ -0,0 +1,44 @@
|
|||
const attributes = {
|
||||
user: [
|
||||
'accountExpires',
|
||||
'badPasswordTime',
|
||||
'badPwdCount',
|
||||
'cn',
|
||||
'department',
|
||||
'description',
|
||||
'displayName',
|
||||
'distinguishedName',
|
||||
'dn',
|
||||
'extensionAttribute1',
|
||||
'extensionAttribute10',
|
||||
'extensionAttribute2',
|
||||
'extensionAttribute6',
|
||||
'extensionAttribute7',
|
||||
'givenName',
|
||||
'homeDirectory',
|
||||
'homeDrive',
|
||||
// 'lastLogoff',
|
||||
'lastLogon',
|
||||
'lastLogonTimestamp',
|
||||
'lockoutTime',
|
||||
'logonCount',
|
||||
'mail',
|
||||
// 'memberOf',
|
||||
'name',
|
||||
'objectCategory',
|
||||
'primaryGroupID',
|
||||
'pwdLastSet',
|
||||
'sAMAccountName',
|
||||
'sAMAccountType',
|
||||
'sn',
|
||||
'thumbnailPhoto',
|
||||
'title',
|
||||
'userAccountControl',
|
||||
'userPrincipalName',
|
||||
'whenChanged',
|
||||
'whenCreated'
|
||||
],
|
||||
group: ['*']
|
||||
}
|
||||
|
||||
export { attributes }
|
14
server/src/utils/activedirectory/config.js
Executable file
|
@ -0,0 +1,14 @@
|
|||
import { attributes } from './attributes'
|
||||
import { entryParser } from './entryParser'
|
||||
|
||||
const config = {
|
||||
url: process.env.AD_URL || 'ldap://ifms.edu.br',
|
||||
baseDN: process.env.AD_BASE_DN || 'dc=ifms,dc=edu,dc=br',
|
||||
username: process.env.AD_BIND_USER || 'ifms\\user',
|
||||
password: process.env.AD_BIND_PASSWORD || 'password',
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
attributes,
|
||||
entryParser
|
||||
}
|
||||
|
||||
export { config as default }
|
19
server/src/utils/activedirectory/encodePassword.js
Executable file
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Encodes a password
|
||||
* @param password
|
||||
* @returns {string}
|
||||
*/
|
||||
const encodePassword = password => {
|
||||
let encodedPassword = ''
|
||||
password = '"' + password + '"'
|
||||
|
||||
for (let i = 0; i < password.length; i++)
|
||||
encodedPassword += String.fromCharCode(
|
||||
password.charCodeAt(i) & 0xff,
|
||||
(password.charCodeAt(i) >>> 8) & 0xff
|
||||
)
|
||||
|
||||
return encodedPassword
|
||||
}
|
||||
|
||||
export { encodePassword }
|
29
server/src/utils/activedirectory/entryParser.js
Executable file
|
@ -0,0 +1,29 @@
|
|||
const entryParser = (entry, raw, callback) => {
|
||||
if (raw.hasOwnProperty('thumbnailPhoto'))
|
||||
entry.thumbnailPhoto = `data:image/png;base64,${raw.thumbnailPhoto.toString(
|
||||
'base64'
|
||||
)}`
|
||||
|
||||
if (raw.hasOwnProperty('pwdLastSet'))
|
||||
entry.pwdLastSet = ldapTimeToJSTime(raw.pwdLastSet)
|
||||
|
||||
if (raw.hasOwnProperty('badPasswordTime'))
|
||||
entry.badPasswordTime = ldapTimeToJSTime(raw.badPasswordTime)
|
||||
|
||||
if (raw.hasOwnProperty('lastLogon'))
|
||||
entry.lastLogon = ldapTimeToJSTime(raw.lastLogon)
|
||||
|
||||
if (raw.hasOwnProperty('lastLogonTimestamp'))
|
||||
entry.lastLogonTimestamp = ldapTimeToJSTime(raw.lastLogonTimestamp)
|
||||
|
||||
if (raw.hasOwnProperty('lockoutTime') && entry.lockoutTime !== '0')
|
||||
entry.lockoutTime = ldapTimeToJSTime(raw.lockoutTime)
|
||||
|
||||
callback(entry)
|
||||
}
|
||||
|
||||
function ldapTimeToJSTime(ldapTime) {
|
||||
return new Date(ldapTime / 1e4 - 1.16444736e13)
|
||||
}
|
||||
|
||||
export { entryParser }
|
18
server/src/utils/activedirectory/index.js
Executable file
|
@ -0,0 +1,18 @@
|
|||
import { promiseWrapper as AD } from 'activedirectory2'
|
||||
import config from './config'
|
||||
|
||||
const ad = new AD(config)
|
||||
|
||||
ad.checkBinding = async () => {
|
||||
try {
|
||||
await ad.authenticate(
|
||||
process.env.AD_BIND_USER,
|
||||
process.env.AD_BIND_PASSWORD
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { ad }
|
77
server/src/utils/activedirectory/passwordUtils.js
Executable file
|
@ -0,0 +1,77 @@
|
|||
import config from './config'
|
||||
import { createClient, Change } from 'ldapjs'
|
||||
import { User } from '../../classes/User'
|
||||
import { encodePassword } from './encodePassword'
|
||||
|
||||
const replacePassword = (username, newPassword) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = createClient(config)
|
||||
|
||||
client.on('error', err => {
|
||||
reject(new Error(err))
|
||||
})
|
||||
|
||||
client.bind(config.username, config.password, (err, result) => {
|
||||
if (!err) {
|
||||
client.search(
|
||||
config.baseDN,
|
||||
{
|
||||
filter: `(sAMAccountName=${username})`,
|
||||
attributes: 'dn',
|
||||
scope: 'sub'
|
||||
},
|
||||
(err, res) => {
|
||||
res.on('error', err => {
|
||||
reject(new Error(err.message))
|
||||
})
|
||||
|
||||
let theEntry = null
|
||||
|
||||
res.on('searchEntry', entry => {
|
||||
theEntry = entry
|
||||
|
||||
const userDN = entry.object.dn
|
||||
|
||||
client.modify(
|
||||
userDN,
|
||||
[
|
||||
new Change({
|
||||
operation: 'replace',
|
||||
modification: {
|
||||
unicodePwd: encodePassword(newPassword)
|
||||
}
|
||||
})
|
||||
],
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
client.unbind(() => {
|
||||
console.log('Client unbinded due error.')
|
||||
})
|
||||
reject(new Error(err.message))
|
||||
} else {
|
||||
User.upsertUser(username)
|
||||
resolve('Senha alterada com sucesso!')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
//@TODO Disparar erro para usuário não encontrado. Solução temporária abaixo
|
||||
res.on('end', result => {
|
||||
setTimeout(() => {
|
||||
if (!theEntry)
|
||||
reject(
|
||||
new Error('Timeout. Provavelmente o usuário não existe')
|
||||
)
|
||||
}, 10000)
|
||||
})
|
||||
}
|
||||
)
|
||||
} else {
|
||||
reject('Problemas técnicos. Tente mais tarde.')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export { replacePassword }
|
5
server/src/utils/capitalize.js
Executable file
|
@ -0,0 +1,5 @@
|
|||
String.prototype.capitalize = function(lower = true) {
|
||||
return (lower ? this.toLowerCase() : this).replace(/(?:^|\s)\S/g, a =>
|
||||
a.toUpperCase()
|
||||
)
|
||||
}
|
16
server/src/utils/saveJSONToFile.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import fs from 'fs'
|
||||
|
||||
function saveJSONToFile(json, fileName = 'output.json') {
|
||||
const jsonContent = JSON.stringify(json)
|
||||
|
||||
fs.writeFile(fileName, jsonContent, 'utf8', function(err) {
|
||||
if (err) {
|
||||
console.log('An error occured while writing JSON Object to File.')
|
||||
return console.log(err)
|
||||
}
|
||||
|
||||
console.log('JSON file has been saved.')
|
||||
})
|
||||
}
|
||||
|
||||
export { saveJSONToFile }
|
96
server/src/utils/unifiController.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
import unifi from 'node-unifi'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const unifiController = new unifi.Controller(
|
||||
process.env.UNIFI_URL || 'unifi.pp.ifms.edu.br',
|
||||
process.env.UNIFI_PORT || 8443
|
||||
)
|
||||
|
||||
const keys = [
|
||||
'login',
|
||||
'logout',
|
||||
'authorizeGuest',
|
||||
'unauthorizeGuest',
|
||||
'reconnectClient',
|
||||
'blockClient',
|
||||
'unblockClient',
|
||||
'setClientNote',
|
||||
'setClientName',
|
||||
'getDailySiteStats',
|
||||
'getHourlySiteStats',
|
||||
'getHourlyApStats',
|
||||
'getDailyApStats',
|
||||
'getSessions',
|
||||
'getLatestSessions',
|
||||
'getAllAuthorizations',
|
||||
'getAllUsers',
|
||||
'getBlockedUsers',
|
||||
'getGuests',
|
||||
'getClientDevices',
|
||||
'getClientDevice',
|
||||
'getUserGroups',
|
||||
'setUserGroup',
|
||||
'editUserGroup',
|
||||
'addUserGroup',
|
||||
'deleteUserGroup',
|
||||
'getHealth',
|
||||
'getDashboard',
|
||||
'getUsers',
|
||||
'getAccessDevices',
|
||||
'getRogueAccessPoints',
|
||||
'getSites',
|
||||
'getSitesStats',
|
||||
'addSite',
|
||||
'deleteSite',
|
||||
'listAdmins',
|
||||
'getWLanGroups',
|
||||
'getSiteSysinfo',
|
||||
'getSelf',
|
||||
'getNetworkConf',
|
||||
'getVouchers',
|
||||
'getPayments',
|
||||
'createHotspotOperator',
|
||||
'getHotspotOperators',
|
||||
'createVouchers',
|
||||
'revokeVoucher',
|
||||
'extendGuestValidity',
|
||||
'getPortForwardingStats',
|
||||
'getDPIStats',
|
||||
'getCurrentChannels',
|
||||
'getPortForwarding',
|
||||
'getDynamicDNS',
|
||||
'getPortConfig',
|
||||
'getVoipExtensions',
|
||||
'getSiteSettings',
|
||||
'adoptDevice',
|
||||
'rebootAccessPoint',
|
||||
'disableAccessPoint',
|
||||
'setLEDOverride',
|
||||
'setLocateAccessPoint',
|
||||
'setSiteLEDs',
|
||||
'setAccessPointRadioSettings',
|
||||
'setGuestLoginSettings',
|
||||
'renameAccessPoint',
|
||||
'createWLan',
|
||||
'deleteWLan',
|
||||
'setWLanSettings',
|
||||
'disableWLan',
|
||||
'getEvents',
|
||||
'getWLanSettings',
|
||||
'getAlarms',
|
||||
'upgradeDevice',
|
||||
'upgradeDeviceExternal',
|
||||
'runSpectrumScan',
|
||||
'getSpectrumScanState',
|
||||
'listRadiusAccounts',
|
||||
'createBackup',
|
||||
'upgradeExternalFirmware'
|
||||
]
|
||||
|
||||
const controller = {}
|
||||
|
||||
for (let key of keys) {
|
||||
controller[key] = promisify(unifiController[key])
|
||||
}
|
||||
|
||||
export { controller }
|
76
server/src/utils/wifiUtils.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { controller } from './unifiController'
|
||||
import { prisma } from '../generated/prisma-client'
|
||||
|
||||
const getOnlineWifiDevices = async () => {
|
||||
try {
|
||||
await controller.login(process.env.UNIFI_USER, process.env.UNIFI_PASSWORD)
|
||||
|
||||
const accessPointsPromise = controller.getAccessDevices('default')
|
||||
const onlineDevicesPromise = controller.getClientDevices('default')
|
||||
|
||||
const [accessPoints, onlineDevices] = await Promise.all([
|
||||
accessPointsPromise,
|
||||
onlineDevicesPromise
|
||||
])
|
||||
|
||||
const onlineDevicesWithUser = onlineDevices[0].map(async client => ({
|
||||
user: client['1x_identity'] || null,
|
||||
oui: client.oui,
|
||||
mac: client.mac,
|
||||
hostname: client.hostname,
|
||||
firstSeen: new Date(client.first_seen * 1000).toISOString(),
|
||||
lastSeen: new Date(client.last_seen * 1000).toISOString(),
|
||||
essid: client.essid,
|
||||
ip: client.ip,
|
||||
uptime: client.uptime.toString(),
|
||||
apName: accessPoints[0].find(ap => ap.mac === client.ap_mac).name,
|
||||
status: 'ONLINE'
|
||||
}))
|
||||
|
||||
controller.logout()
|
||||
|
||||
return Promise.all(onlineDevicesWithUser)
|
||||
} catch (e) {
|
||||
throw new Error('Erro ao listar dispositivos. ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const updateDBWithOnlineDevices = async () => {
|
||||
let onlineDevices = await getOnlineWifiDevices()
|
||||
|
||||
await prisma.updateManyWifiDevices({
|
||||
data: {
|
||||
status: 'OFFLINE'
|
||||
}
|
||||
})
|
||||
|
||||
for (let device of onlineDevices) {
|
||||
|
||||
|
||||
const newDevice = {
|
||||
...device,
|
||||
user: device.user
|
||||
? {
|
||||
connect: {
|
||||
sAMAccountName: device.user
|
||||
}
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.upsertWifiDevice(
|
||||
{
|
||||
where: { mac: device.mac },
|
||||
create: newDevice,
|
||||
update: newDevice
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export { getOnlineWifiDevices, updateDBWithOnlineDevices }
|
2
web/.browserslistrc
Executable file
|
@ -0,0 +1,2 @@
|
|||
> 1%
|
||||
last 2 versions
|
2
web/.env.development
Executable file
|
@ -0,0 +1,2 @@
|
|||
VUE_APP_GRAPHQL_HTTP="http://192.168.0.9:4000/graphql"
|
||||
VUE_APP_GRAPHQL_WS="ws://192.168.0.9:4000/graphql"
|
2
web/.env.production
Normal file
|
@ -0,0 +1,2 @@
|
|||
VUE_APP_GRAPHQL_HTTP="http://ifms-pti-server.pp.ifms.edu.br/graphql"
|
||||
VUE_APP_GRAPHQL_WS="ws://ifms-pti-server.pp.ifms.edu.br/graphql"
|
18
web/.eslintrc.js
Executable file
|
@ -0,0 +1,18 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'no-debugger': 'off'
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint'
|
||||
},
|
||||
|
||||
extends: ['plugin:vue/recommended', '@vue/prettier']
|
||||
}
|
21
web/.gitignore
vendored
Executable file
|
@ -0,0 +1,21 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
12
web/.graphqlconfig
Executable file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"projects": {
|
||||
"ifms-pti": {
|
||||
"schemaPath": "./generated/schema.graphql",
|
||||
"extensions": {
|
||||
"endpoints": {
|
||||
"dev": "http://localhost:4000/graphql"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
web/.prettierrc
Executable file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"excludeFiles": "dist/**"
|
||||
}
|
18
web/Dockerfile
Normal file
|
@ -0,0 +1,18 @@
|
|||
# build stage
|
||||
FROM node as build-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# production stage
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
|
||||
RUN rm -rf /etc/nginx/conf.d
|
||||
RUN mkdir -p /etc/nginx/conf.d
|
||||
COPY ./default.conf /etc/nginx/conf.d/
|
||||
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
25
web/README.md
Executable file
|
@ -0,0 +1,25 @@
|
|||
# Portal de TI
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
npm get-schema
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
3
web/babel.config.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset']
|
||||
}
|
12
web/default.conf
Normal file
|
@ -0,0 +1,12 @@
|
|||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
181
web/generated/schema.graphql
Executable file
|
@ -0,0 +1,181 @@
|
|||
# source: http://localhost:4000/graphql
|
||||
# timestamp: Thu May 07 2020 16:24:38 GMT-0400 (Amazon Standard Time)
|
||||
|
||||
directive @auth(roles: [String!]) on FIELD_DEFINITION
|
||||
|
||||
directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT | INTERFACE
|
||||
|
||||
type AuthPayload {
|
||||
user: User!
|
||||
token: String!
|
||||
expiresIn: String!
|
||||
}
|
||||
|
||||
enum CacheControlScope {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
input CreateResetTokenInput {
|
||||
username: String!
|
||||
}
|
||||
|
||||
type Group {
|
||||
cn: String!
|
||||
dn: String!
|
||||
name: String
|
||||
members: [User!]!
|
||||
}
|
||||
|
||||
input GroupWhereInput {
|
||||
cn: String
|
||||
dn: String
|
||||
name: String
|
||||
}
|
||||
|
||||
input LoginInput {
|
||||
username: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
login(data: LoginInput!): AuthPayload!
|
||||
updatePassword(data: UpdatePasswordInput!): AuthPayload!
|
||||
replacePassword(data: ReplacePasswordInput!): String!
|
||||
createResetToken(data: CreateResetTokenInput!): ResetToken!
|
||||
useResetToken(data: UseResetTokenInput!): Boolean!
|
||||
importUsers: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""Returns only a few fields of User"""
|
||||
basicUser(sAMAccountName: String!): User!
|
||||
me: User!
|
||||
users(where: UserWhereInput!, limit: Int = 15, onlyStudents: Boolean = false): [User!]
|
||||
user(sAMAccountName: String!): User!
|
||||
groups(where: GroupWhereInput!, limit: Int = 10): [Group!]!
|
||||
stats: Stats!
|
||||
wifiDevices(search: String = "", identifiedOnly: Boolean = true): [WifiDevice]!
|
||||
userPresence(search: String): [UserPresence!]
|
||||
}
|
||||
|
||||
input ReplacePasswordInput {
|
||||
username: String!
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
type ResetToken {
|
||||
id: ID!
|
||||
user: User!
|
||||
creator: User!
|
||||
token: String!
|
||||
expiration: String!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Stats {
|
||||
tokenCountTotal: Int!
|
||||
tokenCountUsed: Int!
|
||||
tokenCountExpired: Int!
|
||||
tokenCountNotUsed: Int!
|
||||
}
|
||||
|
||||
enum Status {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
}
|
||||
|
||||
input UpdatePasswordInput {
|
||||
oldPassword: String!
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
"""The `Upload` scalar type represents a file upload."""
|
||||
scalar Upload
|
||||
|
||||
"""A mix between the database User and the Active Directory User"""
|
||||
type User {
|
||||
id: ID
|
||||
wifiDevices: [WifiDevice!]
|
||||
lastLogin: String
|
||||
lastLoginPrior: String
|
||||
roles: [String!]
|
||||
firstName: String
|
||||
groups: [Group!]
|
||||
isSuperAdmin: Boolean!
|
||||
isTokenCreator: Boolean!
|
||||
isServant: Boolean!
|
||||
isStudent: Boolean!
|
||||
isWatcher: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
accountExpires: String
|
||||
badPasswordTime: String
|
||||
badPwdCount: String
|
||||
cn: String
|
||||
department: String
|
||||
description: String
|
||||
displayName: String
|
||||
distinguishedName: String
|
||||
dn: String
|
||||
extensionAttribute1: String
|
||||
extensionAttribute10: String
|
||||
extensionAttribute2: String
|
||||
extensionAttribute6: String
|
||||
extensionAttribute7: String
|
||||
givenName: String
|
||||
homeDirectory: String
|
||||
homeDrive: String
|
||||
lastLogon: String
|
||||
lastLogonTimestamp: String
|
||||
lockoutTime: String
|
||||
logonCount: String
|
||||
mail: String
|
||||
name: String
|
||||
objectCategory: String
|
||||
objectGUID: String
|
||||
objectSid: String
|
||||
primaryGroupID: String
|
||||
pwdLastSet: String
|
||||
sAMAccountName: String!
|
||||
sAMAccountType: String
|
||||
sn: String
|
||||
thumbnailPhoto: String
|
||||
title: String
|
||||
userAccountControl: String
|
||||
userPrincipalName: String
|
||||
whenChanged: String
|
||||
whenCreated: String
|
||||
}
|
||||
|
||||
input UseResetTokenInput {
|
||||
token: String!
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
type UserPresence {
|
||||
user: User!
|
||||
wifiDevices: [WifiDevice!]!
|
||||
}
|
||||
|
||||
input UserWhereInput {
|
||||
cn: String
|
||||
displayName: String
|
||||
sAMAccountName: String
|
||||
}
|
||||
|
||||
type WifiDevice {
|
||||
user: User
|
||||
id: String
|
||||
oui: String
|
||||
mac: String
|
||||
hostname: String
|
||||
firstSeen: String
|
||||
lastSeen: String
|
||||
essid: String
|
||||
ip: String
|
||||
uptime: String
|
||||
apName: String
|
||||
status: Status
|
||||
}
|
21202
web/package-lock.json
generated
Executable file
47
web/package.json
Executable file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "ifms-pti",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"get-schema": "graphql get-schema"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^5.6.55",
|
||||
"core-js": "^3.6.5",
|
||||
"moment": "^2.29.1",
|
||||
"qrcode.vue": "^1.7.0",
|
||||
"roboto-fontface": "*",
|
||||
"validator": "^13.1.17",
|
||||
"vue": "^2.6.12",
|
||||
"vue-apollo": "^3.0.4",
|
||||
"vue-json-pretty": "^1.7.0",
|
||||
"vue-moment": "^4.1.0",
|
||||
"vue-router": "^3.4.6",
|
||||
"vue-the-mask": "^0.11.1",
|
||||
"vuetify": "^2.3.13",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.5.7",
|
||||
"@vue/cli-plugin-eslint": "^4.5.7",
|
||||
"@vue/cli-plugin-router": "^4.5.7",
|
||||
"@vue/cli-service": "^4.5.7",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^7.10.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-vue": "^7.0.1",
|
||||
"graphql-cli": "^4.1.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"prettier": "^2.1.2",
|
||||
"sass": "^1.26.11",
|
||||
"sass-loader": "^10.0.2",
|
||||
"vue-cli-plugin-apollo": "^0.22.2",
|
||||
"vue-cli-plugin-vuetify": "^2.0.7",
|
||||
"vue-template-compiler": "^2.6.12",
|
||||
"vuetify-loader": "^1.6.0"
|
||||
}
|
||||
}
|
BIN
web/public/android-icon-144x144.png
Executable file
After Width: | Height: | Size: 6.3 KiB |
BIN
web/public/android-icon-192x192.png
Executable file
After Width: | Height: | Size: 8.1 KiB |
BIN
web/public/android-icon-36x36.png
Executable file
After Width: | Height: | Size: 1.8 KiB |
BIN
web/public/android-icon-48x48.png
Executable file
After Width: | Height: | Size: 2.1 KiB |
BIN
web/public/android-icon-72x72.png
Executable file
After Width: | Height: | Size: 3.0 KiB |
BIN
web/public/android-icon-96x96.png
Executable file
After Width: | Height: | Size: 3.9 KiB |
BIN
web/public/apple-icon-114x114.png
Executable file
After Width: | Height: | Size: 4.7 KiB |
BIN
web/public/apple-icon-120x120.png
Executable file
After Width: | Height: | Size: 4.8 KiB |
BIN
web/public/apple-icon-144x144.png
Executable file
After Width: | Height: | Size: 6.3 KiB |
BIN
web/public/apple-icon-152x152.png
Executable file
After Width: | Height: | Size: 6.7 KiB |
BIN
web/public/apple-icon-180x180.png
Executable file
After Width: | Height: | Size: 8.3 KiB |
BIN
web/public/apple-icon-57x57.png
Executable file
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/apple-icon-60x60.png
Executable file
After Width: | Height: | Size: 2.5 KiB |
BIN
web/public/apple-icon-72x72.png
Executable file
After Width: | Height: | Size: 3.0 KiB |
BIN
web/public/apple-icon-76x76.png
Executable file
After Width: | Height: | Size: 3.1 KiB |
BIN
web/public/apple-icon-precomposed.png
Executable file
After Width: | Height: | Size: 8.6 KiB |
BIN
web/public/apple-icon.png
Executable file
After Width: | Height: | Size: 8.6 KiB |
2
web/public/browserconfig.xml
Executable file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
BIN
web/public/favicon-16x16.png
Executable file
After Width: | Height: | Size: 1.2 KiB |
BIN
web/public/favicon-32x32.png
Executable file
After Width: | Height: | Size: 1.7 KiB |
BIN
web/public/favicon-96x96.png
Executable file
After Width: | Height: | Size: 3.9 KiB |
BIN
web/public/favicon.ico
Executable file
After Width: | Height: | Size: 1.1 KiB |
BIN
web/public/favicon.png
Executable file
After Width: | Height: | Size: 21 KiB |
64
web/public/index.html
Executable file
|
@ -0,0 +1,64 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<!-- <link rel="icon" href="<%= BASE_URL %>favicon.ico">-->
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" />
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png" />
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="/apple-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="/apple-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="/apple-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="/apple-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-icon-180x180.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/android-icon-192x192.png"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<title>Portal de TI</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong
|
||||
>We're sorry but ifms-pti doesn't work properly without JavaScript
|
||||
enabled. Please enable it to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
41
web/public/manifest.json
Executable file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
BIN
web/public/ms-icon-144x144.png
Executable file
After Width: | Height: | Size: 6.3 KiB |
BIN
web/public/ms-icon-150x150.png
Executable file
After Width: | Height: | Size: 6.6 KiB |
BIN
web/public/ms-icon-310x310.png
Executable file
After Width: | Height: | Size: 18 KiB |
BIN
web/public/ms-icon-70x70.png
Executable file
After Width: | Height: | Size: 2.9 KiB |
95
web/src/App.vue
Executable file
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-overlay :value="$apollo.queries.me.loading" z-index="9">
|
||||
<v-progress-circular color="primary lighten-4" indeterminate size="96" />
|
||||
</v-overlay>
|
||||
<component :is="layout" />
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
color="success"
|
||||
multi-line
|
||||
:timeout="10000"
|
||||
top
|
||||
>
|
||||
{{ message }}
|
||||
<v-btn text icon @click="snackbar = false">
|
||||
<v-icon> mdi-close </v-icon>
|
||||
</v-btn>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Default from './layouts/Default'
|
||||
import Simple from './layouts/Simple'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Default,
|
||||
Simple
|
||||
},
|
||||
data: () => ({
|
||||
snackbar: false
|
||||
}),
|
||||
|
||||
apollo: {
|
||||
me: gql`
|
||||
{
|
||||
me {
|
||||
sAMAccountName
|
||||
displayName
|
||||
department
|
||||
roles
|
||||
groups {
|
||||
cn
|
||||
}
|
||||
thumbnailPhoto
|
||||
firstName
|
||||
pwdLastSet
|
||||
lastLogin
|
||||
lastLoginPrior
|
||||
accountExpires
|
||||
badPwdCount
|
||||
description
|
||||
extensionAttribute1
|
||||
extensionAttribute10
|
||||
extensionAttribute2
|
||||
extensionAttribute6
|
||||
extensionAttribute7
|
||||
mail
|
||||
title
|
||||
isSuperAdmin
|
||||
isStudent
|
||||
isTokenCreator
|
||||
isServant
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
|
||||
computed: {
|
||||
layout() {
|
||||
return this.$route.meta.layout || 'Default'
|
||||
},
|
||||
message() {
|
||||
return this.$route.params.message
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
message(message) {
|
||||
this.snackbar = !!message
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
this.$vuetify.theme.dark = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
BIN
web/src/assets/bg.jpeg
Executable file
After Width: | Height: | Size: 1.7 MiB |
BIN
web/src/assets/bg.jpg
Executable file
After Width: | Height: | Size: 59 KiB |
BIN
web/src/assets/ifms.png
Normal file
After Width: | Height: | Size: 24 KiB |
101
web/src/assets/logoTI.svg
Normal file
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="93.870148mm"
|
||||
height="92.686058mm"
|
||||
viewBox="0 0 93.870148 92.686059"
|
||||
version="1.1"
|
||||
id="svg8">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-48.497227,-75.955887)">
|
||||
<circle
|
||||
style="fill:#cf3034;fill-opacity:1;stroke-width:2.04116;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="path847"
|
||||
cx="131.27765"
|
||||
cy="110.05767"
|
||||
r="11.089727" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect851"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="48.497227"
|
||||
y="75.955887"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect897"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="72.563675"
|
||||
y="75.955887"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect899"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="96.809151"
|
||||
y="75.955887"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect901"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="72.563675"
|
||||
y="100.06919"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect903"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="72.563675"
|
||||
y="124.25967"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect905"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="72.563675"
|
||||
y="148.49739"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect907"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="121.06866"
|
||||
y="148.49739"
|
||||
rx="1.9841913" />
|
||||
<rect
|
||||
style="fill:#3fa14c;fill-opacity:1;stroke-width:1.9842;stroke-linecap:round;paint-order:stroke fill markers"
|
||||
id="rect909"
|
||||
width="20.144552"
|
||||
height="20.144552"
|
||||
x="121.06866"
|
||||
y="124.25967"
|
||||
rx="1.9841913" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
BIN
web/src/assets/serti-dark.png
Executable file
After Width: | Height: | Size: 65 KiB |
BIN
web/src/assets/serti.png
Executable file
After Width: | Height: | Size: 78 KiB |
BIN
web/src/assets/ti.png
Executable file
After Width: | Height: | Size: 21 KiB |
124
web/src/components/AboutCard.vue
Executable file
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<v-card flat>
|
||||
<v-card-title
|
||||
class="font-weight-light"
|
||||
:class="{ 'display-2': $vuetify.breakpoint.mdAndUp }"
|
||||
>
|
||||
Sobre esta aplicação
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
Desenvolvida pelo SERTI de Ponta Porã. Feedback
|
||||
<a href="mailto:serti.pp@ifms.edu.br">serti.pp@ifms.edu.br</a>.
|
||||
</v-card-text>
|
||||
<v-img class="my-5" src="../assets/logoTI.svg" height="256" contain />
|
||||
<v-card-text>
|
||||
<span class="headline">Principais tecnologias utilizadas:</span>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col v-for="group in appTechs" :key="group.subtitle">
|
||||
<v-list two-line>
|
||||
<v-subheader>
|
||||
{{ group.subtitle }}
|
||||
</v-subheader>
|
||||
<v-list-item
|
||||
v-for="(tech, index) in group.techs"
|
||||
:key="index"
|
||||
two-line
|
||||
>
|
||||
<v-list-item-avatar>
|
||||
<v-icon>{{ tech.icon }}</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ tech.title }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<a target="_blank" :href="tech.link">{{ tech.link }}</a>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn large color="primary" :to="{ name: 'home' }" exact>
|
||||
<v-icon left>mdi-home</v-icon>Início
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AboutCard',
|
||||
data: () => ({
|
||||
appTechs: [
|
||||
{
|
||||
subtitle: 'Front-end',
|
||||
techs: [
|
||||
{
|
||||
title: 'VueJS',
|
||||
icon: 'mdi-vuejs',
|
||||
link: 'https://vuejs.org/'
|
||||
},
|
||||
{
|
||||
title: 'VuetifyJS',
|
||||
icon: 'mdi-vuetify',
|
||||
link: 'https://vuetifyjs.com/'
|
||||
},
|
||||
{
|
||||
title: 'GraphQL',
|
||||
icon: 'mdi-graphql',
|
||||
link: 'https://graphql.org/'
|
||||
},
|
||||
{
|
||||
title: 'Apollo Client',
|
||||
icon: 'mdi-alpha-a-circle-outline',
|
||||
link: 'https://www.apollographql.com/'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
subtitle: 'Back-end',
|
||||
techs: [
|
||||
{
|
||||
title: 'GraphQL',
|
||||
icon: 'mdi-graphql',
|
||||
link: 'https://graphql.org/'
|
||||
},
|
||||
{
|
||||
title: 'Apollo Server',
|
||||
icon: 'mdi-alpha-a-circle-outline',
|
||||
link: 'https://www.apollographql.com/'
|
||||
},
|
||||
{
|
||||
title: 'Prisma',
|
||||
icon: 'mdi-database-search',
|
||||
link: 'https://www.prisma.io/'
|
||||
},
|
||||
{
|
||||
title: 'PostgreSQL',
|
||||
icon: 'mdi-database',
|
||||
link: 'https://www.postgresql.org/'
|
||||
},
|
||||
{
|
||||
title: 'Active Directory',
|
||||
icon: 'mdi-microsoft-windows',
|
||||
link: 'https://www.microsoft.com/'
|
||||
},
|
||||
{
|
||||
title: 'LDAP JS',
|
||||
icon: 'mdi-file-tree-outline',
|
||||
link: 'http://ldapjs.org/'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
49
web/src/components/Avatar.vue
Executable file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<v-avatar :size="size" class="avatar" :class="{ left, right }">
|
||||
<v-img
|
||||
:src="
|
||||
src
|
||||
? src
|
||||
: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKUUlEQVR4nMWbe2xb9RXHv+d3r68ftF2SJqVJSkuTrRGtEU2ctKBWgamMiraTioQpHR2EDd88JkCwIjEhoUiwobKNssHaxlYLJX1HUTceg7Gq0JZISYlDEmijtA1aEDSBNA9IGtux7/3tDztZ0vh573X3kSzL9u9877nH93F+53cuIc3IsmzinJcR0a0AigAs45znENEcAHMAMABjkdcQgItE1A3g/Pj4eHN9ff3VdPpH6RCtqalZGAwGtxDRegDlAG7QKBUkomZVVU8AOOLxeC4Y52UYwwLgdDqFjIyMzUT0GIB7EP5nDYWImgG8YbPZ6nfu3OkzRFOvQOQQf4iInkX4EL8efAvgz6Io7t61a9eYHiFdAXC5XHcS0S4Ay/Xo6KAfwG/dbvdhAFyLgKYAVFRUZEiS9FcAv9RibzSc849FUfz17t27v0zVNuUAVFVVrVJV9SiAm1O1TTPfc84f9Xg8x1MxSikALpfLRUR/A2BKybXryx/z8vKera2tVZMZnGwAqLKy8jnO+Qs6HIPJZILdbkdxcTFyc3Mxf/58SJKEoaEhXLlyBd3d3WhpacHIyIiezQDAAQC/crvdwUQDkwkAVVZWvsw5367VG8YY7r77bmzYsAFWqzXuWM45Ojo6cOzYMQwODmrdJDjn/xwZGbmvoaFhIt64hAGQZfk5AC9qdSQ3NxeyLCMvLy8lu4mJCRw/fhwnT57Uumlwzg/n5+dvi3c6CPEEIuf8Tq0OFBQU4KmnnsL8+fNTthUEAXa7HVarFV1dXZq2T0S3jo2NZXi93g9ibifWDy6Xq4yIGuONiceiRYuwffv2hId8IgoKCmC1WnHu3DmtErc7HI6LXq/382g/Rk1XKyoqMojoGDRe7c1mM2RZhiRJWsxnsW7dOhQXF+uRqKuuro6apUYNQCTJuVnr1u6//37ceOONWs2j8vDDD2PevHlazecoinLA6XTOOppnBcDlct0JHRleTk4O1q5dq9U8JjabDevXr9cjUZqZmSlf++WMAMiybIrk9prZuHEjGDN8IggAKC8vx9y5c/VIvFRVVbVg+hczPOWcPwQdExuTyQSHw6HVPCGSJGHVqlV6JH7EOX9m+hdTAXA6nQIR/U6P+vLlyw278MXCbrfrsuecV8uynD35eSoAGRkZmwEs0yO+YsUKPeZJsWzZMr1BvoFz/uTkh6kARCo5uliwYEHiQToRRRHZ2dmJB8aBiB6dvCMwIFzDQ7iMpQu9jiVLZmamXon8rKysu4BIAILB4BborOHl5eUhKytLr2NJYUSgIxf88E5HqreaMZvNePrppyEImrLmlNm0aRNsNptemXsAEHM6nRLCpWvNFBYW6r0/p8S8efNQWFioVya/srLyxywjI6MU2uv2AACitCwvxIVzTTXQa/kpi6zY6KKnpweKohjhUFIoioJLly7p1lFV1c5gQC1/bGwMZ1tadDuULE1NTRgb07UcAAAgoiIGnckPAAhMwP79+/HDDz/odioRV69exaGDByEYM98oYpzzHL0qxAg+nw91e/YY4VRcGhsb4fP7QcYEIIdFVml1Y7ZY0N7egaamJiPkohIIBHDq1CmYzWajJG0M4SVq3RARLFYL/vVBzPKbbs6ePQtVVQ296zAYuIoriiJ6e3vx5Zcpr1Alxb8//BAmk7FrMpPNCYYhSRKOHT1qpCSA8L//n95ew3MOwwPABAFfnDuH9vZ2wzQVRcHRI0fSUWvwM4TbUgzFbDZj3969GB8fN0SvoaEBAwMD6ZhrDDEAF41WFUURIyPfY8/u3bq1Ojs78c7bb8NssRjg2Uw45xdYpCHJcCxWC1q9Xhw6dEizRm9vL15/7TWYJCkthVYi6mYAzhuuHBaH1WrFu++8g8bGxpTte3p68OILL8DvDxh5358B57yLjY+PNwNIuIysBVEUYbZYMDI8nLLtRydPwu/3w2JJz84DgCAIp1l9ff3VSPdVWpAkCaVlZSnblTgcsNpsRqW80RheuHBhBwOASB9eWigoKMBtt92Wsl1xcTGWLl2aBo+m+Ki2tladDO+RdGzBYrFg69atmmyJCNu2bUvb+U9Eh4FIGuzxeC4YfRqYzWY8/vjjWLx4sWaNm266CU888QQsxt8CRwKBwLvAtLX/0tJSAcDPjVC/5ZZbUFVVhSVLlujWysrKwsqVK9Hf348rV64Y4B0A4I29e/f+A5gWgPLy8vPBYPAxaJwdMsZQVFSEBx54AJs3bza0SDp37lzccccdWLx4MUZHRzE0NKSnJqgIgrC1tbV1GLimR0iW5WcAvJys0uROOxwOrFy58rpVhkdHR9He3g6v14vu7m6oalIdcQAAzvlbHo/nkcnPMwJQU1MzJxQKXQSwMJYAEaGwsBBlZWVwOBzXtRwejdHRUXi9Xnz66afo6elJdGQEiWhFXV3dVPo/a24py/IvAByMpVBSUgJZlv8vpfB4cM7hdrvR1tYWb9gf3G73c9O/mDW98nq9X5SUlNxJRDdHU+jr68Ply5dRXFyctkaIVFEUBR6PJ9HOf+Xz+R7s7OyckfVGnV+uXr36DOe8AsCs+08oFMKlixfR2dGBFcuXY84cQypqmvm2vx87duxA+2efxcsZVMbYffv27Zs1840agNbW1uHS0tILALZc+xtjDIwxDHz3HU6dPg2bzYalS5de91NCURScOHECf3n1VQwMDMBitcasFxDR83V1dW9F+y1mhcHr9XY5HI4bAKyZZSQIEEURAb8fn7W1oeXsWeRkZyM3N1fr/iQN5xxtbW3Y+corOHPmDFRVhdVmgyiKsUzeGx4e/s358+ejXh3j/m21tbXs8uXL+wFsi+VMIBBAcCLcjpu/aBHuvfderFmzxvAUNhAIoOmTT/D+++/jm2++AQCYJAlmszne0dfi8/nWxXvwKpleYRPn/O9EtCHWGFVVEfD7EQqFAITT4JKSEqy+/XbY7XbNS9k+nw9ffP45mpub0dbWhkAgAOB/0+wEF+EuAOVutztu+pjUiet0OqXMzMz9AB6MN05VVUxMTCAUDE7dj4kIS5YsQVFREfLz87EwNxc52dmw2mxTgfH5fPD5fBgYGEBfXx++/vprXOjuRm9v7wwd0WSClFx1qAXApkQ7D6TwwERtbS3r6+t7ZXqDUSw45wiFQggFgwgpCqA1bSWCKAgQTSaIopjshfY9n8+3JdnnDVO+dLtcroeIqA4p9BQoigJVVafeuaqCc4DzcApLjIEi74wxCIIw9Z4CKoDn8/LyXkr2aRFA40NT1dXVRYqiHABQqsU+DXzFGHtkz549H6dqqKnQ3traOlhQULDParX2A1iLKAnTdSIIYAeALdPz+1TQnb1UVVUt4Jw/wzmvAaC7cylJFAAHiOj3Wnd8EsPSN1mWsznnTxLRowDyjdK9hhEAhwVB+JOWZwSjYXj+6nQ6hUgT4jbO+c+gPxjDAD4iosOBQODdN99806/byWmkO4Enl8v1E8bYXaqq2omoCOGepBzMPl38AIY45xeIqJtz3iUIwunBwcHOhoaGtHVg/Rdv+XFYpvXd7gAAAABJRU5ErkJggg=='
|
||||
"
|
||||
/>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Avatar',
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '40px'
|
||||
},
|
||||
left: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
right: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar .v-image {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.left {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
75
web/src/components/CreateTokenForm.vue
Executable file
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<v-card :loading="loading" flat>
|
||||
<v-card-title class="font-weight-light display-1 mb-2">
|
||||
Criar token
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<UserSelect v-model="user" students />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn to="/" text>Cancelar</v-btn>
|
||||
<v-btn :disabled="!user" color="primary" large @click="onCreateToken">
|
||||
Criar token
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UserSelect from './UserSelect'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default {
|
||||
name: 'GenerateTokenForm',
|
||||
components: { UserSelect },
|
||||
data: () => ({
|
||||
user: null,
|
||||
loading: false
|
||||
}),
|
||||
methods: {
|
||||
async onCreateToken() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const result = await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($username: String!) {
|
||||
createResetToken(data: { username: $username }) {
|
||||
id
|
||||
token
|
||||
createdAt
|
||||
expiration
|
||||
user {
|
||||
displayName
|
||||
sAMAccountName
|
||||
}
|
||||
creator {
|
||||
displayName
|
||||
sAMAccountName
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
username: this.user
|
||||
}
|
||||
})
|
||||
|
||||
const tokenInfo = result.data.createResetToken
|
||||
|
||||
await this.$router.push({
|
||||
name: 'print-token',
|
||||
params: {
|
||||
...tokenInfo
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
210
web/src/components/LoginForm.vue
Executable file
|
@ -0,0 +1,210 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-card :loading="loading" :elevation="24">
|
||||
<v-scroll-x-reverse-transition mode="out-in">
|
||||
<div v-if="step === 1" key="1">
|
||||
<v-card-title class="justify-center pt-12">
|
||||
<LogoSerti height="96px" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-title class="font-weight-light justify-center mb-6">
|
||||
Para continuar, digite seu usuário
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
:class="{ 'mx-6': $vuetify.breakpoint.mdAndUp }"
|
||||
outlined
|
||||
autofocus
|
||||
rounded
|
||||
name="username"
|
||||
label="Usuário"
|
||||
hint="Servidores: SIAPE. Alunos e colaboradores: CPF."
|
||||
:error-messages="errors"
|
||||
:error="!!errors.length"
|
||||
@keyup.enter="goToPassword"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="mb-12">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:disabled="!username"
|
||||
:loading="loading"
|
||||
color="primary"
|
||||
depressed
|
||||
x-large
|
||||
@click="goToPassword"
|
||||
>
|
||||
Próximo
|
||||
<v-icon right>mdi-chevron-right</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
</v-card-actions>
|
||||
<v-divider />
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text color="primary" to="use-token" large>
|
||||
<v-icon left>mdi-key-variant</v-icon>
|
||||
Tenho um token
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
<div v-else-if="step === 2" key="2">
|
||||
<v-card-title
|
||||
class="font-weight-light justify-center display-1 pa-12"
|
||||
>
|
||||
<Avatar class="mr-4" :src="basicUser.thumbnailPhoto" size="75px" />
|
||||
|
||||
<div>Olá, {{ basicUser.firstName }}!</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:class="{ 'mx-10': $vuetify.breakpoint.mdAndUp }"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
outlined
|
||||
autofocus
|
||||
rounded
|
||||
name="password"
|
||||
label="Senha"
|
||||
:append-icon="
|
||||
showPassword ? 'mdi-eye-outline' : 'mdi-eye-off-outline'
|
||||
"
|
||||
:hint="
|
||||
basicUser.isServant
|
||||
? 'Utilize a senha do SUAP'
|
||||
: 'Caso ainda não tenha uma senha, dirija-se à CEREL e solicite um token.'
|
||||
"
|
||||
:error-messages="errors"
|
||||
:error="!!errors.length"
|
||||
@click:append="showPassword = !showPassword"
|
||||
@keyup.enter="doLogin"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="mb-12">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:disabled="!password"
|
||||
:loading="loading"
|
||||
color="primary"
|
||||
class="px-6"
|
||||
x-large
|
||||
depressed
|
||||
@click="doLogin"
|
||||
>
|
||||
Entrar
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
</v-card-actions>
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text large color="secondary" @click="reset">Voltar</v-btn>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</v-scroll-x-reverse-transition>
|
||||
</v-card>
|
||||
|
||||
<!-- <v-btn class="float-right mt-4" text color="secondary">Ajuda</v-btn>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onLogin, onLogout } from '../plugins/vue-apollo'
|
||||
import gql from 'graphql-tag'
|
||||
import Avatar from './Avatar'
|
||||
import LogoSerti from './LogoSerti'
|
||||
|
||||
export default {
|
||||
name: 'LoginForm',
|
||||
components: { LogoSerti, Avatar },
|
||||
data: () => ({
|
||||
username: '',
|
||||
password: '',
|
||||
showPassword: false,
|
||||
basicUser: undefined,
|
||||
errors: [],
|
||||
step: 1,
|
||||
loading: false
|
||||
}),
|
||||
methods: {
|
||||
reset() {
|
||||
this.username = ''
|
||||
this.password = ''
|
||||
this.showPassword = false
|
||||
this.basicUser = undefined
|
||||
this.errors = []
|
||||
this.step = 1
|
||||
this.loading = false
|
||||
},
|
||||
async goToPassword() {
|
||||
this.loading = true
|
||||
this.errors = []
|
||||
|
||||
try {
|
||||
const response = await this.$apollo.query({
|
||||
query: gql`
|
||||
query($username: String!) {
|
||||
basicUser(sAMAccountName: $username) {
|
||||
displayName
|
||||
firstName
|
||||
thumbnailPhoto
|
||||
isServant
|
||||
isStudent
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
username: this.username
|
||||
}
|
||||
})
|
||||
|
||||
this.basicUser = response.data.basicUser
|
||||
this.step = 2
|
||||
} catch (e) {
|
||||
this.errors = e.graphQLErrors.map(e => e.message)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async doLogin() {
|
||||
this.loading = true
|
||||
this.showPassword = false
|
||||
|
||||
this.errors = []
|
||||
|
||||
try {
|
||||
const response = await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($username: String!, $password: String!) {
|
||||
login(data: { username: $username, password: $password }) {
|
||||
token
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
}
|
||||
})
|
||||
|
||||
const token = response.data.login.token
|
||||
|
||||
await onLogin(this.$apollo.getClient(), token)
|
||||
|
||||
const next = this.$route.params.next
|
||||
await this.$router.push(next ? next : '/')
|
||||
} catch (e) {
|
||||
this.errors = e.graphQLErrors.map(e => e.message)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async doLogout() {
|
||||
await onLogout(this.$apollo.getClient())
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
25
web/src/components/LogoSerti.vue
Executable file
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div>
|
||||
<img
|
||||
v-if="this.$vuetify.theme.dark || dark"
|
||||
src="@/assets/serti-dark.png"
|
||||
:height="height"
|
||||
/>
|
||||
<img v-else src="@/assets/serti.png" :height="height" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LogoSerti',
|
||||
props: {
|
||||
height: {
|
||||
type: String,
|
||||
default: '48px'
|
||||
},
|
||||
dark: {
|
||||
type: Boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
69
web/src/components/MainDrawer.vue
Executable file
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<v-navigation-drawer
|
||||
v-if="me"
|
||||
v-model="drawer"
|
||||
:clipped="$vuetify.breakpoint.mdAndDown"
|
||||
:mini-variant="mini"
|
||||
:mini-variant-width="64"
|
||||
:fixed="$vuetify.breakpoint.mdAndDown"
|
||||
:app="$vuetify.breakpoint.mdAndDown"
|
||||
right
|
||||
@input="$emit('input', drawer)"
|
||||
>
|
||||
<template slot="prepend">
|
||||
<v-img class="ma-4" src="../assets/logoTI.svg" height="48px" contain />
|
||||
</template>
|
||||
|
||||
<NavList :mini="mini" />
|
||||
|
||||
<template slot="append">
|
||||
<v-list nav>
|
||||
<v-list-item @click="mini = !mini">
|
||||
<v-list-item-icon>
|
||||
<v-icon v-if="mini">mdi-chevron-left</v-icon>
|
||||
<v-icon v-else>mdi-chevron-right</v-icon>
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NavList from './NavList'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default {
|
||||
name: 'MainDrawer',
|
||||
components: { NavList },
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
drawer: true,
|
||||
mini: false
|
||||
}),
|
||||
|
||||
watch: {
|
||||
value(e) {
|
||||
this.drawer = e
|
||||
}
|
||||
},
|
||||
|
||||
apollo: {
|
||||
me: {
|
||||
query: gql`
|
||||
{
|
||||
me {
|
||||
sAMAccountName
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
44
web/src/components/NavList.vue
Executable file
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div v-if="me && me.roles">
|
||||
<v-list
|
||||
v-for="(group, groupIndex) in filteredNavItems"
|
||||
:key="groupIndex"
|
||||
nav
|
||||
rounded
|
||||
>
|
||||
<v-subheader v-if="group.groupTitle && !mini">
|
||||
{{ group.groupTitle }}
|
||||
</v-subheader>
|
||||
|
||||
<v-list-item
|
||||
v-for="(item, itemIndex) in group.items"
|
||||
:key="itemIndex"
|
||||
:to="item.route"
|
||||
:disabled="item.disabled"
|
||||
color="primary"
|
||||
exact
|
||||
>
|
||||
<v-list-item-avatar>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-title>
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Nav from '@/mixins/Nav'
|
||||
|
||||
export default {
|
||||
name: 'NavList',
|
||||
mixins: [Nav],
|
||||
props: {
|
||||
mini: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
106
web/src/components/NewPasswordFields.vue
Executable file
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-text-field
|
||||
v-model="newPassword"
|
||||
class="mt-6"
|
||||
:rules="[
|
||||
() =>
|
||||
!newPassword.length ||
|
||||
passwordStrength.score >= minStrength ||
|
||||
'Esta senha é muito simples!'
|
||||
]"
|
||||
:type="show ? 'text' : 'password'"
|
||||
outlined
|
||||
rounded
|
||||
name="password"
|
||||
label="Nova senha"
|
||||
:hint="strengthTips ? passwordStrength.message : ''"
|
||||
:append-icon="show ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
|
||||
validate-on-blur
|
||||
@click:append="show = !show"
|
||||
@keyup="onInput"
|
||||
/>
|
||||
|
||||
<v-expand-transition>
|
||||
<PasswordStrengthMeter
|
||||
v-if="newPassword && strengthTips"
|
||||
:password-strength="passwordStrength.score"
|
||||
/>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-text-field
|
||||
v-show="!show"
|
||||
v-model="passwordConfirmation"
|
||||
:rules="[
|
||||
() =>
|
||||
newPassword === passwordConfirmation || 'As senhas não coincidem.'
|
||||
]"
|
||||
class="mt-6"
|
||||
type="password"
|
||||
outlined
|
||||
rounded
|
||||
name="passwordConfirmation"
|
||||
label="Confirme a nova senha"
|
||||
validate-on-blur
|
||||
@keyup="onInput"
|
||||
/>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PasswordStrengthMeter from './PasswordStrengthMeter'
|
||||
import { passwordStrengthEvaluator } from '../utils/passwordStrengthEvaluator'
|
||||
|
||||
export default {
|
||||
name: 'NewPasswordFields',
|
||||
components: {
|
||||
PasswordStrengthMeter
|
||||
},
|
||||
props: {
|
||||
suggestion: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
newPassword: '',
|
||||
passwordConfirmation: '',
|
||||
show: false,
|
||||
minStrength: 2,
|
||||
strengthTips: true
|
||||
}),
|
||||
computed: {
|
||||
passwordStrength() {
|
||||
return passwordStrengthEvaluator(this.newPassword)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show() {
|
||||
this.onInput()
|
||||
},
|
||||
suggestion(v) {
|
||||
this.strengthTips = false
|
||||
this.newPassword = v
|
||||
this.show = true
|
||||
this.$emit('input', this.newPassword)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.newPassword = this.suggestion
|
||||
},
|
||||
methods: {
|
||||
onInput() {
|
||||
this.strengthTips = true
|
||||
|
||||
if (
|
||||
(this.show || this.newPassword === this.passwordConfirmation) &&
|
||||
this.passwordStrength.score >= this.minStrength
|
||||
)
|
||||
this.$emit('input', this.newPassword)
|
||||
else this.$emit('input', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
30
web/src/components/PasswordStrengthMeter.vue
Executable file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div class="px-1">
|
||||
<v-progress-linear
|
||||
class="font-weight-light pa-0 ma-0"
|
||||
style="font-size: 0.7rem; top: -2.6em"
|
||||
:value="(passwordStrength + 1) / 0.05"
|
||||
:color="strengthColor"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PasswordStrengthMeter',
|
||||
props: {
|
||||
passwordStrength: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
strengthColor() {
|
||||
return ['red', 'orange', 'yellow darken-1', 'teal', 'green'][
|
||||
this.passwordStrength
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
99
web/src/components/ReplacePasswordForm.vue
Executable file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<v-card :loading="loading" flat>
|
||||
<v-card-title class="font-weight-light display-1 mb-2">
|
||||
Alterar uma senha
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<UserSelect v-model="username" />
|
||||
|
||||
<NewPasswordFields v-model="newPassword" :suggestion="suggestion" />
|
||||
|
||||
<v-btn text color="info" @click="generatePassword"
|
||||
><v-icon left>mdi-refresh</v-icon>Gerar</v-btn
|
||||
>
|
||||
<v-btn :disabled="!username" text color="info" @click="defaultPassword"
|
||||
><v-icon left>mdi-account-outline</v-icon> Padrão</v-btn
|
||||
>
|
||||
|
||||
<v-alert color="error" :value="!!errors.length" icon="mdi-alert-circle">
|
||||
<p v-for="(error, index) in errors" :key="index">{{ error }}</p>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text large :to="{ name: 'home' }" exact>Cancelar</v-btn>
|
||||
<v-btn
|
||||
:disabled="!username || !newPassword"
|
||||
color="primary"
|
||||
large
|
||||
@click="onReplacePassword"
|
||||
>
|
||||
Trocar a senha
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import UserSelect from './UserSelect'
|
||||
import NewPasswordFields from './NewPasswordFields'
|
||||
export default {
|
||||
name: 'ReplacePasswordForm',
|
||||
components: {
|
||||
NewPasswordFields,
|
||||
UserSelect
|
||||
},
|
||||
data: () => ({
|
||||
username: null,
|
||||
newPassword: '',
|
||||
suggestion: '',
|
||||
loading: false,
|
||||
errors: []
|
||||
}),
|
||||
methods: {
|
||||
generatePassword() {
|
||||
this.suggestion = Math.random().toString(36).slice(-10)
|
||||
},
|
||||
|
||||
defaultPassword() {
|
||||
this.suggestion = `ifms.${this.username}`
|
||||
},
|
||||
|
||||
async onReplacePassword() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation($username: String!, $newPassword: String!) {
|
||||
replacePassword(
|
||||
data: { username: $username, newPassword: $newPassword }
|
||||
)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
username: this.username,
|
||||
newPassword: this.newPassword
|
||||
}
|
||||
})
|
||||
|
||||
await this.$router.push({
|
||||
name: 'home',
|
||||
params: {
|
||||
message: `A senha do usuário ${this.username} foi alterada!`
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
this.errors = e.graphQLErrors.map(e => e.message)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|