Version 2 initial commit

This commit is contained in:
Douglas Barone 2020-11-06 09:31:28 -04:00
commit 07b607e991
138 changed files with 36126 additions and 0 deletions

0
.gitignore vendored Normal file
View File

4
server/.babelrc Executable file
View File

@ -0,0 +1,4 @@
{
"presets": ["env"],
"plugins": ["transform-object-rest-spread"]
}

37
server/.env.example Executable file
View 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
View File

@ -0,0 +1,7 @@
.vscode
.idea
.env
config/
dist/
generated/
node_modules/

9
server/.prettierrc Executable file
View File

@ -0,0 +1,9 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"bracketSpacing": true,
"arrowParens": "avoid",
"excludeFiles": "dist/**"
}

14
server/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

51
server/package.json Executable file
View 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
View 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/

View 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
View 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
View 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()
)
},
{}
)

View 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
}

View 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
}

View 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
View 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
View File

@ -0,0 +1,5 @@
const Group = {
members: (_, args, { ad }) => ad.getUsersForGroup(_.cn)
}
export { Group }

View 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
View 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 }

View 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
View 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 }

View 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
View 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 }

View 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 }

View File

@ -0,0 +1,7 @@
import { AuthDirective } from './AuthDirective'
const schemaDirectives = {
auth: AuthDirective
}
export { schemaDirectives }

36
server/src/server.js Executable file
View 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
View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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
View File

@ -0,0 +1,5 @@
String.prototype.capitalize = function(lower = true) {
return (lower ? this.toLowerCase() : this).replace(/(?:^|\s)\S/g, a =>
a.toUpperCase()
)
}

View 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 }

View 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 }

View 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
View File

@ -0,0 +1,2 @@
> 1%
last 2 versions

2
web/.env.development Executable file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,12 @@
{
"projects": {
"ifms-pti": {
"schemaPath": "./generated/schema.graphql",
"extensions": {
"endpoints": {
"dev": "http://localhost:4000/graphql"
}
}
}
}
}

10
web/.prettierrc Executable file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset']
}

12
web/default.conf Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

47
web/package.json Executable file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
web/public/android-icon-36x36.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
web/public/android-icon-48x48.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
web/public/android-icon-72x72.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
web/public/android-icon-96x96.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
web/public/apple-icon-114x114.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
web/public/apple-icon-120x120.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
web/public/apple-icon-144x144.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
web/public/apple-icon-152x152.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
web/public/apple-icon-180x180.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
web/public/apple-icon-57x57.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
web/public/apple-icon-60x60.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
web/public/apple-icon-72x72.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
web/public/apple-icon-76x76.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
web/public/apple-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

2
web/public/browserconfig.xml Executable file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
web/public/favicon-32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
web/public/favicon-96x96.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
web/public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
web/public/favicon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

64
web/public/index.html Executable file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
web/public/ms-icon-150x150.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
web/public/ms-icon-310x310.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
web/public/ms-icon-70x70.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

95
web/src/App.vue Executable file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
web/src/assets/bg.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
web/src/assets/ifms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

101
web/src/assets/logoTI.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
web/src/assets/serti.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
web/src/assets/ti.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

124
web/src/components/AboutCard.vue Executable file
View 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
View File

@ -0,0 +1,49 @@
<template>
<v-avatar :size="size" class="avatar" :class="{ left, right }">
<v-img
:src="
src
? src
: 'data:image/png;base64,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'
"
/>
</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>

View 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
View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More